Files
company-ai-platform/app/application/feishu/events.py
JiuContinent eb8267ed18 ```
feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能

- 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避
- 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐
- 增加运行组件心跳检测和readiness就绪检查机制
- 实现app_ticket事件的安全轮换和验证处理
- 添加生产环境运行编排和fail-closed安全机制
- 支持webhook快速确认和长连接独立进程处理
- 完善个人数据擦除时的待处理事件清理功能
```
2026-07-27 17:14:37 +08:00

545 lines
19 KiB
Python

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