ForcePilot/backend/server/routers/ws_chat_router.py
Kris 88588c3740 feat: 实现多渠道网关核心功能
1. 新增多渠道配置项,支持多渠道网关管理
2. 添加用户来源字段,区分本地用户与渠道用户
3. 实现WebSocket聊天路由与Slack Webhook处理
4. 新增渠道消息记录与统计相关存储模型与仓库
5. 实现多渠道管理API与Webhook分发路由
6. 初始化渠道管理器并集成到应用生命周期
7. 添加第三方依赖包支持多渠道SDK
8. 优化鉴权逻辑,排除渠道用户的常规鉴权
2026-05-12 00:56:00 +08:00

220 lines
7.1 KiB
Python

from __future__ import annotations
import asyncio
import json
import time
import uuid
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from jose import JWTError
from yuxi.channels.manager import channel_manager
from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MessageType,
)
from yuxi.utils.logging_config import logger
from server.utils.auth_utils import AuthUtils
MAX_CONNECTIONS_PER_USER = 3
HEARTBEAT_INTERVAL = 30
IDLE_TIMEOUT = 60
MAX_MSG_PER_SEC = 5
ws_chat = APIRouter(tags=["ws_chat"])
class ConnectionRegistry:
def __init__(self):
self._connections: dict[str, list[WebSocket]] = {}
def add(self, user_id: str, ws: WebSocket) -> bool:
if user_id not in self._connections:
self._connections[user_id] = []
if len(self._connections[user_id]) >= MAX_CONNECTIONS_PER_USER:
return False
self._connections[user_id].append(ws)
return True
def remove(self, user_id: str, ws: WebSocket) -> None:
if user_id in self._connections:
try:
self._connections[user_id].remove(ws)
except ValueError:
pass
if not self._connections[user_id]:
del self._connections[user_id]
def count(self, user_id: str) -> int:
return len(self._connections.get(user_id, []))
connections = ConnectionRegistry()
@ws_chat.websocket("/ws/chat/{client_id}")
async def websocket_chat(ws: WebSocket, client_id: str, token: str = Query(None)):
user_id = None
try:
if not token:
await ws.close(code=4001, reason="Missing token")
return
try:
payload = AuthUtils.verify_access_token(token)
user_id = str(payload.get("sub"))
if not user_id:
await ws.close(code=4001, reason="Invalid token")
return
except (JWTError, ValueError):
await ws.close(code=4001, reason="Invalid token")
return
if not connections.add(user_id, ws):
await ws.close(code=4001, reason="Too many connections")
return
await ws.accept()
await ws.send_json({
"type": "connected",
"payload": {"client_id": client_id, "user_id": user_id},
"timestamp": _now_iso(),
})
last_msg_time = time.monotonic()
rate_bucket: list[float] = []
async def heartbeat_sender():
while True:
await asyncio.sleep(HEARTBEAT_INTERVAL)
try:
await ws.send_json({"type": "pong", "payload": {}, "timestamp": _now_iso()})
except Exception:
break
async def idle_monitor():
nonlocal last_msg_time
while True:
await asyncio.sleep(IDLE_TIMEOUT)
if time.monotonic() - last_msg_time > IDLE_TIMEOUT:
logger.info(f"WS idle timeout for client {client_id}")
try:
await ws.close(code=4002, reason="Idle timeout")
except Exception:
pass
break
heartbeat_task = asyncio.create_task(heartbeat_sender())
idle_task = asyncio.create_task(idle_monitor())
try:
while True:
raw = await ws.receive_text()
last_msg_time = time.monotonic()
data = json.loads(raw)
msg_type = data.get("type")
if msg_type == "ping":
await ws.send_json({"type": "pong", "payload": {}, "timestamp": _now_iso()})
continue
if msg_type != "message":
continue
now = time.monotonic()
rate_bucket = [t for t in rate_bucket if now - t < 1.0]
if len(rate_bucket) >= MAX_MSG_PER_SEC:
await ws.send_json({
"type": "error",
"payload": {"code": 429, "message": "Rate limit exceeded"},
"timestamp": _now_iso(),
})
continue
rate_bucket.append(now)
payload = data.get("payload", {})
content = payload.get("content", "")
thread_id = payload.get("thread_id", str(uuid.uuid4()))
agent_config_id = payload.get("agent_config_id", 1)
identity = ChannelIdentity(
channel_id="webchat",
channel_type=ChannelType.WEBCHAT,
channel_user_id=user_id,
channel_chat_id=thread_id,
channel_message_id=f"ws_{uuid.uuid4().hex[:12]}",
)
message = ChannelMessage(
identity=identity,
event_type=EventType.MESSAGE_RECEIVED,
message_type=MessageType.TEXT,
chat_type=ChatType.DIRECT,
content=content,
metadata={
"agent_config_id": agent_config_id,
"thread_id": thread_id,
"user_id": user_id,
},
)
request_id = identity.channel_message_id
await ws.send_json({
"type": "typing",
"payload": {"status": "agent", "request_id": request_id},
"timestamp": _now_iso(),
})
try:
await channel_manager.dispatch_inbound(message)
except Exception as e:
logger.error(f"WS message processing error: {e}")
await ws.send_json({
"type": "error",
"payload": {
"request_id": request_id,
"code": 500,
"message": f"处理出错: {str(e)}",
},
"timestamp": _now_iso(),
})
await ws.send_json({
"type": "done",
"payload": {"request_id": request_id},
"timestamp": _now_iso(),
})
except WebSocketDisconnect:
logger.info(f"WS client {client_id} disconnected")
except Exception as e:
logger.error(f"WS error for client {client_id}: {e}")
finally:
heartbeat_task.cancel()
idle_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
try:
await idle_task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"WS handshake error: {e}")
finally:
if user_id:
connections.remove(user_id, ws)
def _now_iso() -> str:
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
return format_utc_datetime(utc_now_naive())