1. 新增多渠道配置项,支持多渠道网关管理 2. 添加用户来源字段,区分本地用户与渠道用户 3. 实现WebSocket聊天路由与Slack Webhook处理 4. 新增渠道消息记录与统计相关存储模型与仓库 5. 实现多渠道管理API与Webhook分发路由 6. 初始化渠道管理器并集成到应用生命周期 7. 添加第三方依赖包支持多渠道SDK 8. 优化鉴权逻辑,排除渠道用户的常规鉴权
220 lines
7.1 KiB
Python
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())
|