import json from dataclasses import replace from dataclasses import dataclass from hashlib import sha256 from typing import Any from fastapi import HTTPException, status 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, FeishuInboundStatus, FeishuPayloadKey, FeishuResponseKey, ) from app.modules.feishu.service import FeishuService from app.modules.feishu.services import ( FeishuInboundAcceptance, FeishuInboundProcessResult, FeishuInboundService, bind_inbound_event, ) from app.modules.feishu_users.principal import FeishuMention, FeishuPrincipal from app.modules.feishu_users.identifiers import feishu_audit_identity_hash 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, } @dataclass(frozen=True, slots=True) class FeishuEventAcceptance: """Transport-safe acknowledgement for one verified event.""" response: dict[str, Any] event_key: str | None = None created: bool = False should_dispatch: bool = False 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) self.inbox = FeishuInboundService(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]: """Synchronously accept and process a verified event for local callers.""" acceptance = self.accept_verified_event(payload, source, auto_reply=auto_reply) if acceptance.event_key is None: return acceptance.response if not acceptance.should_dispatch: return acceptance.response outcome = self.process_inbound_event( acceptance.event_key, worker_id=f"{source}:inline", ) if outcome.handler_result is not None: return outcome.handler_result return _inbound_response(outcome.record, duplicate=not acceptance.created) def accept_verified_event( self, payload: dict[str, Any], source: str | FeishuEventSource, *, auto_reply: bool = True, ) -> FeishuEventAcceptance: """Durably accept a verified event without executing its command.""" challenge = payload.get(FeishuPayloadKey.CHALLENGE) if challenge: return FeishuEventAcceptance( response={FeishuResponseKey.CHALLENGE: challenge} ) source_value = _normalize_source(source) event_type = _event_type(payload) is_app_ticket = event_type == APP_TICKET_EVENT_TYPE if is_app_ticket: self._validate_app_ticket_event(payload) event_identity = _event_identity(payload, source_value) acceptance = self.inbox.accept( payload, event_identity, event_type=event_type, auto_reply=auto_reply, persist_payload=not is_app_ticket, ) if acceptance.created: self._audit_accepted_event(payload, source_value, acceptance) if is_app_ticket: if acceptance.record.status == FeishuInboundStatus.SUCCEEDED: return FeishuEventAcceptance( response=_inbound_response(acceptance.record, duplicate=True), event_key=acceptance.record.event_key, created=False, should_dispatch=False, ) outcome = self.inbox.process( acceptance.record.event_key, handler_factory=lambda db: ( lambda event_payload, _source, _auto_reply, _event_key: ( FeishuEventService(db)._execute_app_ticket_event( event_payload ) ) ), payload_override=payload, worker_id=f"{source_value}:app-ticket", ) if outcome.record.status != FeishuInboundStatus.SUCCEEDED: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=( "Failed to persist verified Feishu app ticket: " f"{outcome.record.last_error or 'unknown_error'}" ), ) return FeishuEventAcceptance( response=outcome.handler_result or _inbound_response(outcome.record, duplicate=not acceptance.created), event_key=outcome.record.event_key, created=acceptance.created, should_dispatch=False, ) return FeishuEventAcceptance( response=_inbound_response( acceptance.record, duplicate=not acceptance.created, ), event_key=acceptance.record.event_key, created=acceptance.created, should_dispatch=acceptance.should_dispatch, ) def process_inbound_event( self, event_key: str, *, worker_id: str = "feishu-inbound", ) -> FeishuInboundProcessResult: """Claim and process one accepted event.""" return self.inbox.process( event_key, handler_factory=lambda db: FeishuEventService( db )._execute_inbound_event, worker_id=worker_id, ) def process_due_inbound_events( self, *, limit: int, worker_id: str = "feishu-inbound", ) -> list[FeishuInboundProcessResult]: """Recover due retries and expired leases from persistent state.""" return self.inbox.process_due( handler_factory=lambda db: FeishuEventService( db )._execute_inbound_event, limit=limit, worker_id=worker_id, ) def _execute_inbound_event( self, payload: dict[str, Any], _source: str, auto_reply: bool, event_key: str, ) -> dict[str, Any]: 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 ) if command is None: command = self.commands.extract_event_command(payload) if not command: return {FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: False} self.commands.feishu.set_message_uuid(_reply_uuid(event_key)) with bind_inbound_event(event_key): 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 _validate_app_ticket_event( self, payload: dict[str, Any], ) -> tuple[str, str]: 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", ) return app_id, ticket def _execute_app_ticket_event( self, payload: dict[str, Any], ) -> dict[str, Any]: app_id, ticket = self._validate_app_ticket_event(payload) FeishuAppTicketService(self.db).store_verified(app_id, ticket) return { FeishuResponseKey.OK: True, FeishuResponseKey.HANDLED: True, } def _audit_accepted_event( self, payload: dict[str, Any], source: FeishuEventSource, acceptance: FeishuInboundAcceptance, ) -> None: user_features_enabled = get_settings().feishu_user_features_enabled audit_actor = ( _audit_identity_actor(payload) if user_features_enabled else None ) self.feishu.audit.log( AuditLogCreate( actor=audit_actor or ActorValue.FEISHU, source=AuditSource.FEISHU, action=FEISHU_EVENT_ACTIONS[source], target_type=source, target_id=acceptance.record.event_key, request_payload=_audit_event_metadata( payload, include_open_id=not user_features_enabled, include_identity_context=user_features_enabled, ), response_payload={FeishuResponseKey.ACCEPTED: 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 _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: _identifier_digest( header.get(FeishuPayloadKey.EVENT_ID), "event-id", ), FeishuPayloadKey.EVENT_TYPE: _event_type(payload), FeishuPayloadKey.MESSAGE_ID: _identifier_digest( message.get(FeishuPayloadKey.MESSAGE_ID), "message-id", ), FeishuCommandKey.CHAT_ID: _identifier_digest( message.get(FeishuCommandKey.CHAT_ID), "chat-id", ), FeishuPayloadKey.MESSAGE_TYPE: message.get(FeishuPayloadKey.MESSAGE_TYPE), } app_id, _ = _app_ticket_fields(payload) if app_id: metadata[FeishuPayloadKey.APP_ID] = _identifier_digest(app_id, "app-id") if include_identity_context: metadata[FeishuPayloadKey.TENANT_KEY] = _identifier_digest( header.get(FeishuPayloadKey.TENANT_KEY), "tenant-key", ) metadata[FeishuCommandKey.CHAT_TYPE] = message.get(FeishuPayloadKey.CHAT_TYPE) if include_open_id: metadata[FeishuPayloadKey.OPEN_ID] = _identifier_digest( sender_id.get(FeishuPayloadKey.OPEN_ID), "open-id", ) return metadata def _normalize_source(source: str | FeishuEventSource) -> FeishuEventSource: return FeishuEventSource(source) def _audit_identity_actor(payload: dict[str, Any]) -> str | 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 return feishu_audit_identity_hash(tenant_key, open_id) def _event_identity( payload: dict[str, Any], source: str | FeishuEventSource, ) -> dict[str, str | 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 or _payload_fingerprint(payload) event_type = _event_type(payload) app_id, _ = _app_ticket_fields(payload) tenant_key = header.get(FeishuPayloadKey.TENANT_KEY) or app_id or "unknown-tenant" raw_event_key = ":".join( str(part) for part in (tenant_key, event_type or FeishuPayloadKey.EVENT, stable_id) ) event_key = _identifier_digest(raw_event_key, "event-key") return { FeishuEventReceiptKey.EVENT_KEY: event_key, FeishuEventReceiptKey.SOURCE: source_value, FeishuEventReceiptKey.EVENT_ID: _identifier_digest(event_id, "event-id"), FeishuEventReceiptKey.MESSAGE_ID: _identifier_digest( message_id, "message-id", ), } def _payload_fingerprint(payload: dict[str, Any]) -> str: scrubbed = _scrub_transport_secrets(payload) serialized = json.dumps( scrubbed, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ) return f"sha256-{sha256(serialized.encode('utf-8')).hexdigest()}" def _scrub_transport_secrets(value: Any) -> Any: if isinstance(value, dict): return { str(key): _scrub_transport_secrets(item) for key, item in value.items() if str(key).casefold() not in { "access_token", "app_access_token", "app_secret", "app_ticket", "authorization", "encrypt", "refresh_token", "secret", "tenant_access_token", "token", } } if isinstance(value, list): return [_scrub_transport_secrets(item) for item in value] return value def _reply_uuid(event_key: str) -> str: digest = sha256(event_key.encode("utf-8")).hexdigest()[:32] return f"inbound-{digest}" def _identifier_digest(value: Any, domain: str) -> str | None: text = str(value or "").strip() if not text: return None digest = sha256( f"company-ai-platform:feishu:{domain}:v1\0{text}".encode("utf-8") ).hexdigest() return f"sha256-{digest}" def _inbound_response( record: Any, *, duplicate: bool, ) -> dict[str, Any]: response = { FeishuResponseKey.OK: True, FeishuResponseKey.ACCEPTED: True, FeishuResponseKey.HANDLED: record.status == FeishuInboundStatus.SUCCEEDED, FeishuResponseKey.STATUS: record.status, } if duplicate: response[FeishuResponseKey.DUPLICATE] = True return response 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