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

包含以下功能模块:
- client: Mattermost API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedup: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- interactions: 交互处理
- slash_commands: 斜杠指令
- actions: 动作处理
- approval: 审批流程
- delivery: 消息送达确认
- directory: 目录管理
- threading: 线程管理
- gating: 门控管理
- reconnect: 重连机制
- reactions: 表情反应
- media: 媒体资源处理
- model_picker: 模型选择
- types: 类型定义
2026-05-21 11:22:43 +08:00

222 lines
8.0 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import websockets
from websockets.asyncio.client import ClientConnection
from websockets.exceptions import ConnectionClosed
from yuxi.channel.extensions.mattermost.client import MattermostClient
from yuxi.channel.extensions.mattermost.errors import MattermostAuthError
from yuxi.channel.extensions.mattermost.types import MattermostPost
logger = logging.getLogger(__name__)
PING_INTERVAL_SECONDS = 30
PONG_TIMEOUT_SECONDS = 10
HEALTH_CHECK_INTERVAL_SECONDS = 30
class MattermostWebSocketMonitor:
def __init__(
self,
client: MattermostClient,
account_id: str = "",
on_posted=None,
on_reaction_added=None,
on_reaction_removed=None,
on_post_edited=None,
on_post_deleted=None,
on_user_added=None,
on_user_removed=None,
on_user_updated=None,
on_channel_created=None,
on_channel_deleted=None,
on_channel_updated=None,
on_typing=None,
on_status_change=None,
on_thread_updated=None,
):
self.client = client
self.account_id = account_id
self._on_posted = on_posted
self._on_reaction_added = on_reaction_added
self._on_reaction_removed = on_reaction_removed
self._on_post_edited = on_post_edited
self._on_post_deleted = on_post_deleted
self._on_user_added = on_user_added
self._on_user_removed = on_user_removed
self._on_user_updated = on_user_updated
self._on_channel_created = on_channel_created
self._on_channel_deleted = on_channel_deleted
self._on_channel_updated = on_channel_updated
self._on_typing = on_typing
self._on_status_change = on_status_change
self._on_thread_updated = on_thread_updated
self._ws: ClientConnection | None = None
self._abort = asyncio.Event()
self._connected = asyncio.Event()
self.bot_user_id: str = ""
self.bot_last_update_at: int = 0
@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()
self._connected.clear()
ws_url = self.client.base_url.replace("http", "ws") + "/api/v4/websocket"
logger.info("Mattermost WS connecting to %s", ws_url)
self._ws = await websockets.connect(ws_url, ping_interval=None)
await self._authenticate()
logger.info("Mattermost WS authenticated for account %s", self.account_id)
self._connected.set()
try:
await asyncio.gather(
self._event_loop(),
self._ping_loop(),
self._health_check_loop(),
)
except asyncio.CancelledError:
pass
except ConnectionClosed:
logger.warning("Mattermost WS connection closed for account %s", self.account_id)
finally:
await self._cleanup()
async def disconnect(self) -> None:
self._abort.set()
await self._cleanup()
async def _authenticate(self) -> None:
auth_msg = {
"seq": 1,
"action": "authentication_challenge",
"data": {"token": self.client.bot_token},
}
await self._ws.send(json.dumps(auth_msg))
response = await asyncio.wait_for(self._ws.recv(), timeout=10)
data = json.loads(response)
if data.get("status") != "OK":
raise MattermostAuthError(401, "WebSocket authentication failed")
async def _event_loop(self) -> None:
while not self._abort.is_set() and self._ws:
try:
message = await asyncio.wait_for(self._ws.recv(), timeout=60)
await self._handle_message(message)
except TimeoutError:
continue
except ConnectionClosed:
break
async def _ping_loop(self) -> None:
while not self._abort.is_set() and self._ws:
try:
await asyncio.sleep(PING_INTERVAL_SECONDS)
await self._ws.send(json.dumps({"seq": 1, "action": "ping"}))
try:
pong = await asyncio.wait_for(self._ws.recv(), timeout=PONG_TIMEOUT_SECONDS)
data = json.loads(pong)
if data.get("event") != "pong" and data.get("status") != "OK":
logger.warning("Unexpected pong response: %s", data)
except TimeoutError:
logger.warning("Pong timeout for account %s", self.account_id)
break
except ConnectionClosed:
break
async def _health_check_loop(self) -> None:
while not self._abort.is_set():
await asyncio.sleep(HEALTH_CHECK_INTERVAL_SECONDS)
try:
me = await self.client.fetch_me()
update_at = me.get("update_at", 0)
if self.bot_last_update_at and update_at != self.bot_last_update_at:
logger.warning(
"Bot update_at changed (%d -> %d), reconnecting",
self.bot_last_update_at,
update_at,
)
break
self.bot_last_update_at = update_at
except Exception:
pass
async def _handle_message(self, raw: str) -> None:
try:
data = json.loads(raw)
except json.JSONDecodeError:
return
event = data.get("event", "")
if event == "hello":
return
event_data = data.get("data", {})
if event == "posted":
post_data = event_data.get("post", "")
if post_data and self._on_posted:
post = _parse_post(post_data)
if post:
await self._on_posted(post, event_data)
elif event == "reaction_added" and self._on_reaction_added:
await self._on_reaction_added(event_data)
elif event == "reaction_removed" and self._on_reaction_removed:
await self._on_reaction_removed(event_data)
elif event == "post_edited" and self._on_post_edited:
post_data = event_data.get("post", "")
post = _parse_post(post_data)
if post:
await self._on_post_edited(post, event_data)
elif event == "post_deleted" and self._on_post_deleted:
await self._on_post_deleted(event_data)
elif event == "user_added" and self._on_user_added:
await self._on_user_added(event_data)
elif event == "user_removed" and self._on_user_removed:
await self._on_user_removed(event_data)
elif event == "user_updated" and self._on_user_updated:
await self._on_user_updated(event_data)
elif event == "channel_created" and self._on_channel_created:
await self._on_channel_created(event_data)
elif event == "channel_deleted" and self._on_channel_deleted:
await self._on_channel_deleted(event_data)
elif event == "channel_updated" and self._on_channel_updated:
await self._on_channel_updated(event_data)
elif event == "typing" and self._on_typing:
await self._on_typing(event_data)
elif event == "status_change" and self._on_status_change:
await self._on_status_change(event_data)
elif event == "thread_updated" and self._on_thread_updated:
await self._on_thread_updated(event_data)
async def _cleanup(self) -> None:
if self._ws:
try:
await self._ws.close()
except Exception:
pass
self._ws = None
self._connected.clear()
def _parse_post(post_data: str | dict) -> MattermostPost | None:
if isinstance(post_data, str):
try:
post_data = json.loads(post_data)
except json.JSONDecodeError:
return None
if not isinstance(post_data, dict):
return None
return MattermostPost.from_dict(post_data)