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