feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能 - 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避 - 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐 - 增加运行组件心跳检测和readiness就绪检查机制 - 实现app_ticket事件的安全轮换和验证处理 - 添加生产环境运行编排和fail-closed安全机制 - 支持webhook快速确认和长连接独立进程处理 - 完善个人数据擦除时的待处理事件清理功能 ```
237 lines
7.1 KiB
Python
237 lines
7.1 KiB
Python
import json
|
|
import logging
|
|
from socket import gethostname
|
|
from threading import Event, Thread
|
|
from time import monotonic
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
from app.core.background.task_queue import enqueue_feishu_inbound_event
|
|
from app.core.config import get_settings
|
|
from app.core.constants import ActorValue
|
|
from app.core.database import SessionLocal
|
|
from app.application.feishu import FeishuEventService
|
|
from app.modules.feishu.constants import (
|
|
FEISHU_DEFAULT_OPEN_API_DOMAIN,
|
|
FeishuEventSource,
|
|
FeishuEventTransport,
|
|
FeishuResponseKey,
|
|
)
|
|
from app.modules.observability.constants import HeartbeatComponent, HeartbeatStatus
|
|
from app.modules.observability.service import ObservabilityService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _sdk_domain(base_url: str) -> str:
|
|
parsed = urlsplit(base_url)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return FEISHU_DEFAULT_OPEN_API_DOMAIN
|
|
return f"{parsed.scheme}://{parsed.netloc}"
|
|
|
|
|
|
def _sdk_connection_is_open(client: Any) -> bool:
|
|
"""Return whether the SDK exposes a currently open WebSocket connection."""
|
|
|
|
try:
|
|
connection = getattr(client, "_conn", None)
|
|
except Exception:
|
|
return False
|
|
if connection is None:
|
|
return False
|
|
|
|
try:
|
|
state = getattr(connection, "state", None)
|
|
except Exception:
|
|
return False
|
|
if state is not None:
|
|
state_name = getattr(state, "name", None)
|
|
state_text = str(state_name or state).strip().lower()
|
|
if state_text == "open" or state_text.endswith(".open"):
|
|
return True
|
|
if any(
|
|
marker in state_text
|
|
for marker in ("connecting", "closing", "closed")
|
|
):
|
|
return False
|
|
|
|
try:
|
|
closed = getattr(connection, "closed", None)
|
|
except Exception:
|
|
return False
|
|
if isinstance(closed, bool):
|
|
return not closed
|
|
|
|
try:
|
|
opened = getattr(connection, "open", None)
|
|
except Exception:
|
|
return False
|
|
if isinstance(opened, bool):
|
|
return opened
|
|
|
|
try:
|
|
if getattr(connection, "close_code", None) is not None:
|
|
return False
|
|
transport = getattr(connection, "transport", None)
|
|
is_closing = getattr(transport, "is_closing", None)
|
|
if callable(is_closing) and is_closing():
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
# A non-null SDK-private connection object is not enough evidence that the
|
|
# WebSocket handshake completed or that the transport remains usable.
|
|
return False
|
|
|
|
|
|
def _connection_heartbeat_status(client: Any) -> str:
|
|
if _sdk_connection_is_open(client):
|
|
return HeartbeatStatus.OK
|
|
return HeartbeatStatus.DEGRADED
|
|
|
|
|
|
def _record_runtime_heartbeat(instance_id: str, status_value: str) -> None:
|
|
db = SessionLocal()
|
|
try:
|
|
ObservabilityService(db).record_heartbeat(
|
|
component=HeartbeatComponent.FEISHU_EVENTS,
|
|
instance_id=instance_id,
|
|
status_value=status_value,
|
|
actor=ActorValue.FEISHU,
|
|
)
|
|
except Exception:
|
|
db.rollback()
|
|
logger.exception("Failed to record Feishu event process heartbeat")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _heartbeat_loop(
|
|
stop_event: Event,
|
|
instance_id: str,
|
|
interval_seconds: int,
|
|
client: Any,
|
|
) -> None:
|
|
last_status: str | None = None
|
|
next_heartbeat_at = 0.0
|
|
while not stop_event.is_set():
|
|
status_value = _connection_heartbeat_status(client)
|
|
current_time = monotonic()
|
|
if status_value != last_status or current_time >= next_heartbeat_at:
|
|
_record_runtime_heartbeat(instance_id, status_value)
|
|
last_status = status_value
|
|
next_heartbeat_at = current_time + max(1, interval_seconds)
|
|
stop_event.wait(1)
|
|
|
|
|
|
def _start_heartbeat_loop(client: Any) -> tuple[Event, Thread]:
|
|
settings = get_settings()
|
|
stop_event = Event()
|
|
thread = Thread(
|
|
target=_heartbeat_loop,
|
|
args=(
|
|
stop_event,
|
|
gethostname(),
|
|
settings.heartbeat_interval_seconds,
|
|
client,
|
|
),
|
|
name="feishu-events-heartbeat",
|
|
daemon=True,
|
|
)
|
|
thread.start()
|
|
return stop_event, thread
|
|
|
|
|
|
def _sdk_event_to_payload(event: Any) -> dict[str, Any]:
|
|
try:
|
|
from lark_oapi.core.json import JSON
|
|
except ImportError as exc:
|
|
raise RuntimeError("lark-oapi is required for Feishu long connection") from exc
|
|
|
|
data = JSON.marshal(event)
|
|
if not data:
|
|
return {}
|
|
return json.loads(data)
|
|
|
|
|
|
def _handle_message_event(event: Any) -> None:
|
|
_handle_verified_sdk_event(event)
|
|
|
|
|
|
def _handle_app_ticket_event(event: Any) -> None:
|
|
_handle_verified_sdk_event(event)
|
|
|
|
|
|
def _handle_verified_sdk_event(event: Any) -> None:
|
|
payload = _sdk_event_to_payload(event)
|
|
db = SessionLocal()
|
|
try:
|
|
acceptance = FeishuEventService(db).accept_verified_event(
|
|
payload,
|
|
source=FeishuEventSource.LONG_CONNECTION,
|
|
auto_reply=True,
|
|
)
|
|
finally:
|
|
db.close()
|
|
if (
|
|
acceptance.event_key is not None
|
|
and acceptance.should_dispatch
|
|
and get_settings().task_queue_enabled
|
|
):
|
|
enqueue_feishu_inbound_event(
|
|
acceptance.event_key,
|
|
actor=ActorValue.FEISHU,
|
|
)
|
|
logger.info(
|
|
"Accepted Feishu long connection event key=%s status=%s duplicate=%s",
|
|
acceptance.event_key,
|
|
acceptance.response.get(FeishuResponseKey.STATUS),
|
|
bool(acceptance.response.get(FeishuResponseKey.DUPLICATE)),
|
|
)
|
|
|
|
|
|
def run_long_connection() -> None:
|
|
"""Start the Feishu long connection client and block forever."""
|
|
|
|
settings = get_settings()
|
|
if settings.feishu_event_transport != FeishuEventTransport.LONG_CONNECTION:
|
|
raise RuntimeError(
|
|
"FEISHU_EVENT_TRANSPORT must be long_connection for this process"
|
|
)
|
|
if not settings.feishu_app_id or not settings.feishu_app_secret:
|
|
raise RuntimeError("FEISHU_APP_ID and FEISHU_APP_SECRET are required")
|
|
|
|
try:
|
|
import lark_oapi as lark
|
|
except ImportError as exc:
|
|
raise RuntimeError("Install lark-oapi before starting Feishu long connection") from exc
|
|
|
|
event_handler = (
|
|
lark.EventDispatcherHandler.builder(
|
|
settings.feishu_encrypt_key or "",
|
|
settings.feishu_verification_token or "",
|
|
)
|
|
.register_p2_im_message_receive_v1(_handle_message_event)
|
|
.register_p1_customized_event("app_ticket", _handle_app_ticket_event)
|
|
.build()
|
|
)
|
|
client = lark.ws.Client(
|
|
app_id=settings.feishu_app_id,
|
|
app_secret=settings.feishu_app_secret,
|
|
event_handler=event_handler,
|
|
log_level=lark.LogLevel.WARNING,
|
|
domain=_sdk_domain(settings.feishu_base_url),
|
|
)
|
|
logger.info("Starting Feishu long connection client")
|
|
stop_event, heartbeat_thread = _start_heartbeat_loop(client)
|
|
try:
|
|
client.start()
|
|
finally:
|
|
stop_event.set()
|
|
heartbeat_thread.join(timeout=1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(level=logging.INFO)
|
|
run_long_connection()
|