本次提交新增了完整的多渠道消息网关系统,包括: 1. 支持飞书、钉钉、Web、Hook 四种渠道的适配器与配置 2. 领域模型层:消息、会话、绑定、出箱等核心实体 3. 应用服务层:管道、中间件、DTO 与业务逻辑 4. 基础设施层:持久化、过滤器、队列等端口实现 5. 接口层:REST API、SSE、WebSocket 通信端点 6. 前端页面与路由配置,添加渠道管理菜单 7. 新增相关依赖包与 docker-compose 部署配置
122 lines
4.0 KiB
Python
122 lines
4.0 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import threading
|
|
|
|
import lark_oapi as lark
|
|
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FeishuWsConnection:
|
|
def __init__(self, *, app_id: str, app_secret: str) -> None:
|
|
self._app_id = app_id
|
|
self._app_secret = app_secret
|
|
self._ws_client: lark.ws.Client | None = None
|
|
self._loop: asyncio.AbstractEventLoop | None = None
|
|
self._on_message = None
|
|
self._connected = False
|
|
self._thread: threading.Thread | None = None
|
|
|
|
@property
|
|
def channel_type(self) -> str:
|
|
return "feishu"
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
return self._connected
|
|
|
|
async def start(
|
|
self,
|
|
loop: asyncio.AbstractEventLoop,
|
|
on_message,
|
|
) -> None:
|
|
self._loop = loop
|
|
self._on_message = on_message
|
|
|
|
event_handler = (
|
|
lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(self._on_ws_event).build()
|
|
)
|
|
|
|
self._ws_client = lark.ws.Client(
|
|
self._app_id,
|
|
self._app_secret,
|
|
event_handler=event_handler,
|
|
log_level=lark.LogLevel.INFO,
|
|
)
|
|
|
|
self._thread = threading.Thread(target=self._run_ws_client, daemon=True, name="feishu-ws")
|
|
self._thread.start()
|
|
|
|
await self._wait_for_connection(timeout=10.0)
|
|
logger.info(
|
|
"feishu ws started, app_id=%s",
|
|
self._app_id[:4] + "..." if self._app_id else "N/A",
|
|
)
|
|
|
|
def _run_ws_client(self) -> None:
|
|
try:
|
|
self._connected = True
|
|
self._ws_client.start()
|
|
except Exception:
|
|
self._connected = False
|
|
logger.exception("feishu ws client crashed")
|
|
|
|
async def _wait_for_connection(self, timeout: float = 10.0) -> None:
|
|
elapsed = 0.0
|
|
interval = 0.5
|
|
while elapsed < timeout:
|
|
if self._thread and not self._thread.is_alive():
|
|
self._connected = False
|
|
logger.error("feishu ws thread exited prematurely")
|
|
return
|
|
await asyncio.sleep(interval)
|
|
elapsed += interval
|
|
|
|
async def stop(self) -> None:
|
|
self._connected = False
|
|
logger.warning("feishu ws stop: SDK does not provide stop(), daemon thread will exit with process")
|
|
|
|
def _on_ws_event(self, data: P2ImMessageReceiveV1) -> None:
|
|
raw = self._event_to_raw(data)
|
|
if self._loop and self._on_message:
|
|
try:
|
|
future = asyncio.run_coroutine_threadsafe(self._on_message(raw), self._loop)
|
|
future.add_done_callback(self._callback_done)
|
|
except RuntimeError:
|
|
logger.warning("feishu ws bridge: event loop is closed")
|
|
|
|
def _event_to_raw(self, data: P2ImMessageReceiveV1) -> dict:
|
|
msg = data.event.message
|
|
sender = data.event.sender
|
|
sender_id = sender.sender_id if sender else None
|
|
return {
|
|
"channel_type": "feishu",
|
|
"header": {"event_id": getattr(data.header, "event_id", "") or ""},
|
|
"event": {
|
|
"message": {
|
|
"message_id": getattr(msg, "message_id", "") or "",
|
|
"chat_id": getattr(msg, "chat_id", "") or "",
|
|
"chat_type": getattr(msg, "chat_type", "") or "",
|
|
"message_type": getattr(msg, "message_type", "") or "",
|
|
"content": getattr(msg, "content", "") or "",
|
|
},
|
|
"sender": {
|
|
"sender_id": {
|
|
"user_id": getattr(sender_id, "user_id", "") or "",
|
|
"open_id": getattr(sender_id, "open_id", "") or "",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@staticmethod
|
|
def _callback_done(future):
|
|
try:
|
|
future.result()
|
|
except Exception:
|
|
logger.exception("ws→asyncio bridge error")
|