Files
company-ai-platform/app/application/feishu/events.py
JiuContinent d7db84571d ```
feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
2026-07-27 08:02:17 +08:00

340 lines
12 KiB
Python

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