ForcePilot/backend/package/yuxi/channel/extensions/rocketchat/websocket.py
Kris 043e75d787 feat(channel): 添加 RocketChat 渠道扩展
新增 RocketChat 渠道扩展,支持在 Yuxi 平台中集成 RocketChat 团队协作平台。

包含以下功能模块:
- client: RocketChat API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedup: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- gating: 门控管理
- threading: 线程管理
- reactions: 表情反应
- types: 类型定义
2026-05-21 11:39:24 +08:00

265 lines
8.7 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import websockets
logger = logging.getLogger(__name__)
PING_INTERVAL_SECONDS = 30
SUBSCRIBE_TIMEOUT_SECONDS = 10
class RocketChatDDPClient:
def __init__(
self,
server_url: str,
user_id: str,
auth_token: str,
on_message=None,
):
self.server_url = server_url.rstrip("/")
self.user_id = user_id
self.auth_token = auth_token
self.ws_url = self.server_url.replace("http", "ws") + "/websocket"
self._on_message = on_message
self._ws: websockets.WebSocketClientProtocol | None = None
self._session_id: str = ""
self._msg_counter: int = 0
self._pending_subs: dict[str, asyncio.Event] = {}
self._pending_calls: dict[str, asyncio.Future] = {}
self._active_subs: dict[str, str] = {}
self._abort = asyncio.Event()
@property
def is_connected(self) -> bool:
return self._ws is not None and not self._abort.is_set()
async def connect(self) -> None:
self._abort.clear()
logger.info("Rocket.Chat DDP connecting to %s", self.ws_url)
self._ws = await websockets.connect(self.ws_url, ping_interval=None)
await self._send(
{
"msg": "connect",
"version": "1",
"support": ["1", "pre2", "pre1"],
}
)
resp = await self._recv()
if resp.get("msg") != "connected":
raise ConnectionError(f"DDP handshake failed: {resp}")
self._session_id = resp.get("session", "")
logger.info("Rocket.Chat DDP connected, session=%s", self._session_id)
result = await self._call("login", [{"resume": self.auth_token}])
if not result:
raise ConnectionError("DDP login failed")
logger.info("Rocket.Chat DDP authenticated for user %s", self.user_id)
await self._subscribe("stream-room-messages", ["__my_messages__", False])
logger.info("Rocket.Chat DDP subscribed to stream-room-messages")
try:
await self._subscribe("stream-notify-user", [f"{self.user_id}/message", False])
logger.info("Rocket.Chat DDP subscribed to stream-notify-user")
except ConnectionError as e:
logger.warning("Failed to subscribe stream-notify-user: %s", e)
try:
await self._subscribe(
"stream-notify-room",
[f"{self.user_id}/rooms-changed", False],
)
logger.info("Rocket.Chat DDP subscribed to stream-notify-room (rooms-changed)")
except ConnectionError as e:
logger.warning("Failed to subscribe stream-notify-room: %s", e)
async def disconnect(self) -> None:
self._abort.set()
if self._ws:
try:
await self._ws.close()
except Exception:
pass
self._ws = None
async def listen(self, abort_event: asyncio.Event) -> None:
ping_task = asyncio.create_task(self._ping_loop(abort_event))
try:
async for raw in self._ws:
if abort_event.is_set():
break
msg = json.loads(raw)
await self._handle_frame(msg)
except asyncio.CancelledError:
pass
except Exception as e:
if not abort_event.is_set():
logger.warning("Rocket.Chat DDP listen error: %s", e)
raise
finally:
ping_task.cancel()
try:
await ping_task
except asyncio.CancelledError:
pass
async def _handle_frame(self, msg: dict) -> None:
msg_type = msg.get("msg", "")
if msg_type == "ping":
await self._send({"msg": "pong"})
elif msg_type == "pong":
pass
elif msg_type == "ready":
sub_id = msg.get("id", "")
event = self._pending_subs.pop(sub_id, None)
if event:
event.set()
elif msg_type == "result":
call_id = msg.get("id", "")
future = self._pending_calls.pop(call_id, None)
if future and not future.done():
result = msg.get("result")
error = msg.get("error")
if error:
future.set_exception(ConnectionError(f"DDP method error: {error}"))
else:
future.set_result(result)
elif msg_type == "changed":
await self._handle_changed(msg)
elif msg_type == "nosub":
sub_id = msg.get("id", "")
error = msg.get("error")
logger.warning("DDP subscription %s failed: %s", sub_id, error)
event = self._pending_subs.pop(sub_id, None)
if event:
event.set()
elif msg_type == "added":
pass
elif msg_type == "removed":
pass
async def _handle_changed(self, msg: dict) -> None:
collection = msg.get("collection", "")
fields = msg.get("fields", {})
args = fields.get("args", [])
if collection == "stream-room-messages" and args:
if self._on_message:
for arg in args:
if isinstance(arg, dict):
try:
await self._on_message(arg)
except Exception as e:
logger.error("Error in DDP message handler: %s", e)
elif collection == "stream-notify-room" and args:
if self._on_message:
for arg in args:
if isinstance(arg, dict):
try:
arg["_ddp_event"] = "notify-room"
await self._on_message(arg)
except Exception as e:
logger.error("Error in DDP notify-room handler: %s", e)
elif collection == "stream-notify-user" and args:
if self._on_message:
for arg in args:
if isinstance(arg, dict):
try:
arg["_ddp_event"] = "notify-user"
await self._on_message(arg)
except Exception as e:
logger.error("Error in DDP notify-user handler: %s", e)
async def _send(self, msg: dict) -> None:
if self._ws:
await self._ws.send(json.dumps(msg))
async def _recv(self) -> dict:
raw = await self._ws.recv()
return json.loads(raw)
async def _call(self, method: str, params: list) -> dict | None:
msg_id = f"call-{self._msg_counter}"
self._msg_counter += 1
loop = asyncio.get_running_loop()
future = loop.create_future()
self._pending_calls[msg_id] = future
await self._send(
{
"msg": "method",
"method": method,
"params": params,
"id": msg_id,
}
)
try:
return await asyncio.wait_for(future, timeout=15.0)
except TimeoutError:
self._pending_calls.pop(msg_id, None)
raise ConnectionError(f"DDP method {method} timed out")
async def _subscribe(self, stream_name: str, params: list) -> str:
sub_id = f"sub-{stream_name}-{self._msg_counter}"
self._msg_counter += 1
event = asyncio.Event()
self._pending_subs[sub_id] = event
await self._send(
{
"msg": "sub",
"id": sub_id,
"name": stream_name,
"params": params,
}
)
try:
await asyncio.wait_for(event.wait(), timeout=SUBSCRIBE_TIMEOUT_SECONDS)
except TimeoutError:
self._pending_subs.pop(sub_id, None)
raise ConnectionError(f"Subscription {stream_name} timed out")
self._active_subs[sub_id] = stream_name
return sub_id
async def unsubscribe(self, sub_id: str) -> None:
self._active_subs.pop(sub_id, None)
await self._send({"msg": "unsub", "id": sub_id})
logger.debug("DDP unsubscribed %s", sub_id)
async def subscribe_room_event(self, room_id: str, event: str) -> str:
return await self._subscribe("stream-notify-room", [f"{room_id}/{event}", False])
async def _ping_loop(self, abort_event: asyncio.Event) -> None:
while not abort_event.is_set():
try:
await asyncio.sleep(PING_INTERVAL_SECONDS)
if not abort_event.is_set() and self._ws:
await self._send({"msg": "ping"})
except asyncio.CancelledError:
break
except Exception:
break