新增 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: 类型定义
265 lines
8.7 KiB
Python
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
|