ForcePilot/backend/package/yuxi/channel/extensions/qqbot/websocket.py
Kris 2ab65f153f feat(channel): 添加 QQ Bot 渠道扩展
新增 QQ Bot 渠道扩展,支持在 Yuxi 平台中集成 QQ 机器人渠道。

包含以下功能模块:
- api_client: QQ API 客户端封装
- api_routes: API 路由管理
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- credentials: 凭证管理
- token: Token 管理
- outbound: 外发消息管理
- outbound_media: 媒体外发
- streaming: 流式消息处理
- streaming_media: 媒体流处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- pipeline: 消息管道
- pipeline_stages: 管道阶段
- commands: 指令处理
- commands_builtin: 内置指令
- interaction: 交互处理
- approval: 审批流程
- ark: ARK 消息
- audio: 音频处理
- media: 媒体资源
- media_chunked: 分块媒体
- media_tags: 媒体标签
- message_queue: 消息队列
- delivery: 消息送达确认
- reconnect: 重连机制
- typing_keepalive: 输入状态保活
- group_activation: 群激活
- group_gating: 群门控
- group_history: 群历史
- known_users: 已知用户
- ref_index: 引用索引
- tools: Agent 工具集成
- types: 类型定义
2026-05-21 11:35:12 +08:00

293 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any, Callable
import websockets
import websockets.exceptions
from yuxi.channel.extensions.qqbot.api_client import QQBotApiClient
from yuxi.channel.extensions.qqbot.errors import classify_close_code
from yuxi.channel.extensions.qqbot.reconnect import ReconnectStateMachine
from yuxi.channel.extensions.qqbot.session import SessionStore
from yuxi.channel.extensions.qqbot.types import (
FULL_INTENTS,
GatewayEvent,
QQBotOpCode,
SessionState,
)
logger = logging.getLogger(__name__)
class QQBotGatewayConnection:
RECONNECT_OP = 7
def __init__(
self,
api_client: QQBotApiClient,
session_store: SessionStore,
account_id: str = "default",
dispatch_handler: Callable[[GatewayEvent], Any] | None = None,
reconnect_handler: Callable[[], Any] | None = None,
):
self._api_client = api_client
self._session_store = session_store
self._account_id = account_id
self._dispatch_handler = dispatch_handler
self._reconnect_handler = reconnect_handler
self._ws: websockets.WebSocketClientProtocol | None = None
self._session_id: str | None = None
self._last_seq: int | None = None
self._heartbeat_interval: int = 30000
self._heartbeat_task: asyncio.Task | None = None
self._message_task: asyncio.Task | None = None
self._connected = False
self._cancel_event = asyncio.Event()
self._reconnect_state = ReconnectStateMachine()
self._bot_openid: str | None = None
self._resume_boundary_seq: int | None = None
@property
def connected(self) -> bool:
return self._connected
@property
def session_id(self) -> str | None:
return self._session_id
@property
def last_seq(self) -> int | None:
return self._last_seq
@property
def bot_openid(self) -> str | None:
return self._bot_openid
async def start(self) -> None:
self._cancel_event.clear()
session = self._session_store.load(self._account_id)
if session and session.session_id:
self._session_id = session.session_id
self._last_seq = session.last_seq
await self._connect()
async def stop(self) -> None:
self._cancel_event.set()
await self._disconnect()
self._save_session()
async def _connect(self) -> None:
gateway_url = await self._api_client.get_gateway_url()
if not gateway_url:
raise RuntimeError("Failed to obtain gateway URL")
logger.info("[qqbot:%s] Connecting to gateway: %s", self._account_id, gateway_url)
try:
self._ws = await websockets.connect(
gateway_url,
ping_interval=None,
close_timeout=5,
max_size=2**20,
)
except Exception as e:
raise RuntimeError(f"WebSocket connection failed: {e}") from e
self._connected = True
self._message_task = asyncio.create_task(self._message_loop(), name=f"qqbot-ws-{self._account_id}")
async def _disconnect(self) -> None:
self._connected = False
await self._cancel_heartbeat()
if self._message_task and not self._message_task.done():
self._message_task.cancel()
try:
await self._message_task
except asyncio.CancelledError:
pass
self._message_task = None
if self._ws:
try:
await self._ws.close(1000)
except Exception:
pass
self._ws = None
async def _message_loop(self) -> None:
assert self._ws is not None
try:
async for raw_message in self._ws:
if self._cancel_event.is_set():
break
try:
data = json.loads(raw_message)
event = GatewayEvent.from_dict(data)
await self._handle_event(event)
except json.JSONDecodeError:
logger.warning("[qqbot:%s] Invalid JSON message", self._account_id)
except websockets.exceptions.ConnectionClosed as e:
self._connected = False
should_retry, delay = classify_close_code(e.code)
if should_retry:
logger.warning("[qqbot:%s] Connection closed: code=%d, reason=%s", self._account_id, e.code, e.reason)
if self._reconnect_handler:
await self._reconnect_handler()
else:
logger.error("[qqbot:%s] Fatal connection close: code=%d", self._account_id, e.code)
raise
except Exception:
self._connected = False
logger.exception("[qqbot:%s] Message loop error", self._account_id)
if self._reconnect_handler and not self._cancel_event.is_set():
await self._reconnect_handler()
async def _handle_event(self, event: GatewayEvent) -> None:
if event.s is not None:
self._last_seq = event.s
if event.op == QQBotOpCode.HELLO:
await self._on_hello(event)
elif event.op == QQBotOpCode.HEARTBEAT_ACK:
logger.debug("[qqbot:%s] Heartbeat ACK", self._account_id)
elif event.op == QQBotOpCode.RECONNECT:
logger.info("[qqbot:%s] Server requested reconnect", self._account_id)
if self._reconnect_handler:
await self._reconnect_handler()
elif event.op == QQBotOpCode.INVALID_SESSION:
logger.warning("[qqbot:%s] Invalid session, clearing session", self._account_id)
self._session_id = None
self._last_seq = None
if self._reconnect_handler:
await self._reconnect_handler()
elif event.op == QQBotOpCode.DISPATCH:
await self._on_dispatch(event)
async def _on_hello(self, event: GatewayEvent) -> None:
d = event.d or {}
self._heartbeat_interval = d.get("heartbeat_interval", 30000)
logger.info(
"[qqbot:%s] HELLO received, heartbeat_interval=%dms",
self._account_id,
self._heartbeat_interval,
)
await self._send_identify_or_resume()
self._start_heartbeat()
async def _send_identify_or_resume(self) -> None:
assert self._ws is not None
try:
token_str = await self._api_client._token_manager.get_token()
except Exception as e:
logger.error("[qqbot:%s] Failed to get token for identify: %s", self._account_id, e)
raise
token = f"QQBot {token_str}"
if self._session_id is not None and self._last_seq is not None:
self._resume_boundary_seq = self._last_seq
payload = {
"op": QQBotOpCode.RESUME,
"d": {
"token": token,
"session_id": self._session_id,
"seq": self._last_seq,
},
}
logger.info("[qqbot:%s] Sending RESUME: session_id=%s, boundary_seq=%d", self._account_id, self._session_id, self._resume_boundary_seq)
else:
payload = {
"op": QQBotOpCode.IDENTIFY,
"d": {
"token": token,
"intents": FULL_INTENTS,
"shard": [0, 1],
},
}
logger.info("[qqbot:%s] Sending IDENTIFY", self._account_id)
await self._ws.send(json.dumps(payload, separators=(",", ":")))
def _start_heartbeat(self) -> None:
if self._heartbeat_task and not self._heartbeat_task.done():
return
interval = max(self._heartbeat_interval, 1000) / 1000.0
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop(interval), name=f"qqbot-hb-{self._account_id}")
async def _cancel_heartbeat(self) -> None:
if self._heartbeat_task and not self._heartbeat_task.done():
self._heartbeat_task.cancel()
try:
await self._heartbeat_task
except asyncio.CancelledError:
pass
self._heartbeat_task = None
async def _heartbeat_loop(self, interval: float) -> None:
while not self._cancel_event.is_set():
try:
await asyncio.wait_for(self._cancel_event.wait(), timeout=interval)
return
except TimeoutError:
pass
if self._ws and self._connected:
try:
payload = {
"op": QQBotOpCode.HEARTBEAT,
"d": self._last_seq,
}
await self._ws.send(json.dumps(payload, separators=(",", ":")))
logger.debug("[qqbot:%s] Heartbeat sent, seq=%s", self._account_id, self._last_seq)
except Exception:
logger.exception("[qqbot:%s] Heartbeat send failed", self._account_id)
async def _on_dispatch(self, event: GatewayEvent) -> None:
event_type = event.t or ""
d = event.d or {}
if self._resume_boundary_seq is not None and event.s is not None and event.s <= self._resume_boundary_seq:
logger.debug(
"[qqbot:%s] Skipping replayed event: type=%s, seq=%d <= boundary=%d",
self._account_id, event_type, event.s, self._resume_boundary_seq,
)
return
if event_type == "READY":
self._session_id = d.get("session_id")
self._bot_openid = d.get("user", {}).get("id")
self._resume_boundary_seq = None
logger.info(
"[qqbot:%s] READY: session_id=%s, bot_openid=%s",
self._account_id,
self._session_id,
self._bot_openid,
)
self._save_session()
elif event_type == "RESUMED":
logger.info("[qqbot:%s] RESUMED", self._account_id)
self._resume_boundary_seq = None
if self._dispatch_handler:
try:
await self._dispatch_handler(event)
except Exception:
logger.exception("[qqbot:%s] Dispatch handler error for event %s", self._account_id, event_type)
def _save_session(self) -> None:
state = SessionState(
session_id=self._session_id,
last_seq=self._last_seq,
last_connected_at=time.time(),
account_id=self._account_id,
saved_at=time.time(),
)
self._session_store.save(self._account_id, state)