ForcePilot/backend/package/yuxi/channels/adapters/qqbot/reconnect.py
Kris ef5483dc1a refactor(qqbot): 重构QQ机器人适配器代码,优化多项功能与结构
主要变更:
1. 修复速率限流器使用setdefault替代重复创建令牌桶
2. 重构交互注册表匹配逻辑,优化精确匹配查找
3. 重构去重缓存逻辑,移到适配器实例方法
4. 重构发送URL解析,增加合法性校验并拆分公共方法
5. 优化流式消息处理逻辑,简化flush_controller调用
6. 重构群聊类型判断代码,简化语法
7. 修复重连管理器对None类型关闭分类的处理
8. 新增消息缓存、线程模拟器、发送初始化模块
9. 重构凭证备份与会话存储逻辑,支持环境变量指定状态目录
10. 新增配置提示与向导二维码绑定功能
11. 优化媒体上传逻辑,增加重试机制与缓存
12. 新增审批键盘模板构建函数
13. 重构消息格式处理,修正媒体发送字段与长度限制
14. 修复令牌过期时间计算,使用time.time替代monotonic
15. 新增群组激活缓冲区与用户追踪器增强功能
16. 修复换行符问题,统一文件结尾格式
2026-05-13 16:13:48 +08:00

323 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import logging
import random
import time
from collections.abc import Awaitable, Callable
from enum import Enum, auto
from yuxi.channels.adapters.qqbot.constants import SERVER_CLOSE_CODE_MAP, ECode
logger = logging.getLogger(__name__)
_RAPID_DISCONNECT_THRESHOLD_S = 5.0
_RAPID_DISCONNECT_MAX_WARNINGS = 3
class ReconnectState(Enum):
DISCONNECTED = auto()
CONNECTING = auto()
CONNECTED = auto()
RECONNECTING = auto()
IDENTIFYING = auto()
RESUMING = auto()
BACKOFF = auto()
FROZEN = auto()
class CloseCodeCategory(Enum):
ABNORMAL = auto()
RECOVERABLE = auto()
FATAL = auto()
SERVER_SIDE = auto()
SERVER_ERROR = auto()
RATE_LIMITED = auto()
class ServerErrorCategory(Enum):
OVERLOAD = auto()
MAINTENANCE = auto()
NETWORK = auto()
INTERNAL = auto()
UNAVAILABLE = auto()
TIMEOUT = auto()
UNKNOWN = auto()
_RECOVERABLE_CODES: set[int] = {
ECode.UNKNOWN_ERROR,
ECode.UNKNOWN_OPCODE,
ECode.DECODE_ERROR,
ECode.NOT_AUTHENTICATED,
ECode.AUTHENTICATION_FAILED,
ECode.RATE_LIMITED,
4009,
ECode.INVALID_INTENT,
ECode.INVALID_SHARD,
}
_FATAL_CODES: set[int] = {
ECode.INVALID_API_VERSION,
ECode.INVALID_SEQ,
4013,
ECode.INVALID_SHARD_COUNT,
ECode.BOT_REMOVED,
ECode.ACCOUNT_BANNED,
}
_ABNORMAL_CODES: set[int] = {4000, 4008, 4011}
_RATELIMIT_CODES: set[int] = {ECode.RATE_LIMITED, 4009}
_SERVER_ERROR_CODES: set[int] = set(range(4900, 4914))
_SERVER_OVERLOAD_CODES: set[int] = {4901, 4904, 4912}
_SERVER_MAINTENANCE_CODES: set[int] = {4902, 4905}
_SERVER_NETWORK_CODES: set[int] = {4903, 4906, 4907}
_SERVER_UNAVAILABLE_CODES: set[int] = {4908, 4909}
_SERVER_TIMEOUT_CODES: set[int] = {4913}
_SERVER_INTERNAL_CODES: set[int] = {4900, 4910, 4911}
def classify_server_error_category(code: int) -> ServerErrorCategory:
if code in _SERVER_OVERLOAD_CODES:
return ServerErrorCategory.OVERLOAD
if code in _SERVER_MAINTENANCE_CODES:
return ServerErrorCategory.MAINTENANCE
if code in _SERVER_NETWORK_CODES:
return ServerErrorCategory.NETWORK
if code in _SERVER_UNAVAILABLE_CODES:
return ServerErrorCategory.UNAVAILABLE
if code in _SERVER_TIMEOUT_CODES:
return ServerErrorCategory.TIMEOUT
if code in _SERVER_INTERNAL_CODES:
return ServerErrorCategory.INTERNAL
return ServerErrorCategory.UNKNOWN
def get_server_error_name(code: int) -> str:
return SERVER_CLOSE_CODE_MAP.get(code, f"server_error_{code}")
def classify_close_code(code: int | None) -> CloseCodeCategory:
if code is None:
return CloseCodeCategory.ABNORMAL
if code in _RATELIMIT_CODES:
return CloseCodeCategory.RATE_LIMITED
if code in _FATAL_CODES:
return CloseCodeCategory.FATAL
if code in _RECOVERABLE_CODES:
return CloseCodeCategory.RECOVERABLE
if code in _ABNORMAL_CODES:
return CloseCodeCategory.ABNORMAL
if code in _SERVER_ERROR_CODES:
return CloseCodeCategory.SERVER_ERROR
return CloseCodeCategory.SERVER_SIDE if 4000 <= code < 5000 else CloseCodeCategory.ABNORMAL
class QQBotReconnectManager:
def __init__(
self,
base_delay: float = 1.0,
max_delay: float = 60.0,
jitter: float = 0.3,
max_retries: int = 10,
resume_timeout: float = 15.0,
) -> None:
self._state = ReconnectState.DISCONNECTED
self._base_delay = base_delay
self._max_delay = max_delay
self._jitter = jitter
self._max_retries = max_retries
self._resume_timeout = resume_timeout
self._retry_count = 0
self._session_id: str | None = None
self._last_seq: int | None = None
self._seq_reset_lock = asyncio.Lock()
self._state_listeners: list[Callable[[ReconnectState, ReconnectState], Awaitable[None]]] = []
self._last_connect_time: float = 0.0
self._rapid_disconnect_count: int = 0
self._last_disconnect_code: int | None = None
self._last_disconnect_time: float = 0.0
@property
def state(self) -> ReconnectState:
return self._state
@property
def session_id(self) -> str | None:
return self._session_id
@property
def last_seq(self) -> int | None:
return self._last_seq
def add_state_listener(self, listener: Callable[[ReconnectState, ReconnectState], Awaitable[None]]) -> None:
self._state_listeners.append(listener)
async def _notify_state_change(self, old: ReconnectState, new: ReconnectState) -> None:
for listener in self._state_listeners:
try:
await listener(old, new)
except Exception:
logger.exception("ReconnectManager state listener error")
async def transition(self, new: ReconnectState) -> None:
old = self._state
if old == new:
return
self._state = new
logger.info("ReconnectManager: %s -> %s", old.name, new.name)
await self._notify_state_change(old, new)
def on_identify_success(self, session_id: str) -> None:
self._session_id = session_id
self._retry_count = 0
self._last_connect_time = time.monotonic()
def mark_connected(self) -> None:
self._last_connect_time = time.monotonic()
self._rapid_disconnect_count = 0
async def record_seq(self, seq: int) -> None:
async with self._seq_reset_lock:
self._last_seq = seq
def seq_reset(self) -> None:
self._last_seq = None
def on_hello(self) -> None:
pass
def should_resume(self) -> bool:
return self._session_id is not None and self._last_seq is not None
async def on_disconnect(self, code: int | None) -> None:
now = time.monotonic()
self._last_disconnect_code = code
self._last_disconnect_time = now
category = classify_close_code(code)
self._check_rapid_disconnect(code, category, now)
if category == CloseCodeCategory.FATAL:
logger.error("ReconnectManager: fatal close code %s, freezing", code)
await self.transition(ReconnectState.FROZEN)
return
if category == CloseCodeCategory.SERVER_ERROR:
error_name = get_server_error_name(code) if code else "unknown"
sub_category = classify_server_error_category(code) if code else ServerErrorCategory.UNKNOWN
logger.warning(
"ReconnectManager: server error code=%s name=%s category=%s",
code,
error_name,
sub_category.name,
)
self._retry_count += 1
if self._retry_count >= self._max_retries:
logger.error("ReconnectManager: server error retries exhausted, freezing")
await self.transition(ReconnectState.FROZEN)
return
delay = self._calc_delay_for_server_error(sub_category)
logger.warning(
"ReconnectManager: server error close code %s (%s), backing off %.1fs (retry %d/%d)",
code,
error_name,
delay,
self._retry_count,
self._max_retries,
)
self.seq_reset()
self._session_id = None
await self.transition(ReconnectState.BACKOFF)
await asyncio.sleep(delay)
await self.transition(ReconnectState.IDENTIFYING)
return
if category == CloseCodeCategory.RATE_LIMITED:
self._retry_count += 1
if self._retry_count > 3:
logger.error("ReconnectManager: rate-limited retries exhausted, freezing")
await self.transition(ReconnectState.FROZEN)
return
self.seq_reset()
if self._retry_count >= self._max_retries:
logger.error("ReconnectManager: max retries (%d) exhausted", self._max_retries)
await self.transition(ReconnectState.FROZEN)
return
self._retry_count += 1
delay = self._calc_delay()
logger.info("ReconnectManager: backing off %.1fs (retry %d/%d)", delay, self._retry_count, self._max_retries)
await self.transition(ReconnectState.BACKOFF)
await asyncio.sleep(delay)
if self.should_resume():
await self.transition(ReconnectState.RESUMING)
else:
self.seq_reset()
await self.transition(ReconnectState.IDENTIFYING)
def _calc_delay(self) -> float:
raw = min(self._base_delay * (2 ** (self._retry_count - 1)), self._max_delay)
jittered = raw * (1 + random.uniform(-self._jitter, self._jitter))
return max(0.5, min(jittered, self._max_delay))
def _calc_delay_for_server_error(self, sub_category: ServerErrorCategory) -> float:
base = self._calc_delay()
if sub_category == ServerErrorCategory.OVERLOAD:
return base * 3.0
if sub_category == ServerErrorCategory.MAINTENANCE:
return base * 5.0
if sub_category == ServerErrorCategory.NETWORK:
return base * 1.5
if sub_category == ServerErrorCategory.TIMEOUT:
return base * 1.5
return base * 2.0
def _check_rapid_disconnect(self, code: int | None, category: CloseCodeCategory | None, now: float) -> None:
if category is None or category == CloseCodeCategory.FATAL:
return
if self._last_connect_time == 0:
return
elapsed = now - self._last_connect_time
if elapsed > _RAPID_DISCONNECT_THRESHOLD_S:
self._rapid_disconnect_count = 0
return
self._rapid_disconnect_count += 1
if self._rapid_disconnect_count >= _RAPID_DISCONNECT_MAX_WARNINGS:
logger.error(
"ReconnectManager: RAPID DISCONNECT LOOP DETECTED - "
"%d disconnects within %.1fs threshold, last code=%s category=%s elapsed=%.2fs",
self._rapid_disconnect_count,
_RAPID_DISCONNECT_THRESHOLD_S,
code,
category.name,
elapsed,
)
else:
logger.warning(
"ReconnectManager: rapid disconnect #%d/%d within %.1fs threshold, code=%s category=%s elapsed=%.2fs",
self._rapid_disconnect_count,
_RAPID_DISCONNECT_MAX_WARNINGS,
_RAPID_DISCONNECT_THRESHOLD_S,
code,
category.name,
elapsed,
)
async def reset(self) -> None:
self._retry_count = 0
self._session_id = None
self._last_seq = None
self._last_connect_time = 0.0
self._rapid_disconnect_count = 0
self._last_disconnect_code = None
self._last_disconnect_time = 0.0
await self.transition(ReconnectState.DISCONNECTED)