feat: 添加飞书用户模块和订阅功能支持 - 新增feishu_users模块用于处理飞书用户身份验证和权限管理 - 新增subscriptions模块用于处理订阅相关功能 - 新增personalization模块用于个性化服务 - 在alembic迁移配置中注册新的模型模块 - 在API路由器中添加feishu_users和subscriptions路由 - 实现事件调度服务的改进,包括错误处理和状态更新优化 - 添加飞书命令处理的权限检查机制 - 实现飞书应用票据事件处理 - 改进审计日志记录功能 ```
340 lines
12 KiB
Python
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
|