ForcePilot/backend/package/yuxi/channels/plugins/qqbot/lifecycle.py
Kris c888db4e1f refactor(qqbot): 重构并完善QQ Bot适配器功能
1. 优化HTTP客户端关闭异常捕获范围
2. 重构事件工具方法抽取公共模块
3. 新增频控缓存常量与身份缓存配置
4. 完善适配器依赖注入与配置读取
5. 实现被动消息频控与令牌刷新串行化
6. 修复会话、状态、目录等适配器逻辑
7. 优化探测适配器账户获取逻辑
8. 完善凭据轮换与生命周期钩子
9. 优化WebSocket连接处理与心跳机制
2026-07-09 04:19:12 +08:00

178 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""QQ Bot 渠道插件生命周期钩子处理。
实现 ``LifecycleHookHandler`` Protocol管理插件级资源httpx 连接池),
响应宿主生命周期状态变迁。
设计要点:
- LifecycleHandler 为单实例(非 per-account可持有 mutable 状态用于
资源管理(与适配器的 INV-5 无 mutable 状态约束不同)。
- httpx 连接池在 ``onInit`` 创建并注入 ``QQBotClient````onUnload``
解除引用后关闭;连接数上限对齐 manifest.json 中
``resource_quota.max_connections=20``。
- WebSocket 长连接由 ``StreamWorker`` 通过 ``QQBotStreamConnectorAdapter``
统一管理,``LifecycleHandler`` 不再持有 WS 客户端句柄。
- ``onReconfigure`` 从配置 schema 派生不可热更新键列表
``hot_reloadable=False``),变更时抛 ``ConfigRestartRequiredError``。
``config_schema`` 为必填,不再有硬编码回退清单(避免与 manifest 不一致)。
- ``onFail`` 不抛异常不阻断降级流程logger 不可用时回退 ``stderr``。
依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx
不污染框架层。
"""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any
import httpx
from yuxi.channels.contract.dtos.config import ConfigField
from yuxi.channels.contract.errors import ConfigRestartRequiredError
from yuxi.channels.contract.ports.driven.cache_port import CachePort
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
from ._constants import (
HTTP_MAX_CONNECTIONS,
HTTP_MAX_KEEPALIVE_CONNECTIONS,
HTTP_TIMEOUT_SECONDS,
)
if TYPE_CHECKING:
from .qqbot_client import QQBotClient
class QQBotLifecycleHandler:
"""QQ Bot 渠道插件生命周期钩子处理。
单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池,通过
``attach_http_client`` 注入 ``QQBotClient`` 供其复用(避免每请求新建
TCP 连接);``onReconfigure`` 通过入参 ``old_config`` 做差异化校验,
不再自行维护基线缓存。不可热更新键列表从配置 schema 派生
``hot_reloadable=False````config_schema`` 必填。WebSocket 连接由
``StreamWorker`` 管理,本处理器不持有 WS 句柄。
"""
def __init__(
self,
config_port: ConfigPort,
logger_port: LoggerPort,
cache_port: CachePort,
config_schema: tuple[ConfigField, ...],
client: QQBotClient | None = None,
) -> None:
self._config = config_port
self._logger = logger_port
self._cache = cache_port
self._client = client
self._http_client: httpx.AsyncClient | None = None
self._started: bool = False
# 从 config_schema 派生不可热更新键hot_reloadable=False 的字段 key
# 不再硬编码回退清单以避免与 manifest config_schema 不一致
self._restart_required_keys: tuple[str, ...] = tuple(
field.key for field in config_schema if not field.hot_reloadable
)
# ------------------------------------------------------------------
# LifecycleHookHandler Protocol 实现
# ------------------------------------------------------------------
async def onInit(self) -> None:
"""初始化资源:创建 httpx 连接池并注入 QQBotClient。"""
self._http_client = httpx.AsyncClient(
timeout=HTTP_TIMEOUT_SECONDS,
limits=httpx.Limits(
max_connections=HTTP_MAX_CONNECTIONS,
max_keepalive_connections=HTTP_MAX_KEEPALIVE_CONNECTIONS,
),
)
if self._client is not None:
self._client.attach_http_client(self._http_client)
await self._logger.info("QQBot plugin initialized")
async def onStart(self) -> None:
"""标记插件就绪。"""
self._started = True
await self._logger.info("QQBot plugin started")
async def onStop(self) -> None:
"""停止插件:标记未就绪,等待在途请求完成。"""
await self._logger.info(
"QQBot plugin stopping, waiting for in-flight requests",
)
self._started = False
async def onPause(self) -> None:
"""暂停插件:停止接收新请求。"""
self._started = False
await self._logger.info("QQBot plugin paused")
async def onResume(self) -> None:
"""恢复插件:重新接收请求。"""
self._started = True
await self._logger.info("QQBot plugin resumed")
async def onUnload(self) -> None:
"""释放所有资源:解除 QQBotClient 引用后关闭 httpx 连接池。
WebSocket 连接由 ``TransportManager.stop()`` 在宿主关停时首先关闭,
本方法不再处理 WS 客户端清理。先 ``detach_http_client`` 解除 client
引用(避免悬挂),再 ``aclose`` 关闭连接池。
"""
if self._client is not None:
self._client.detach_http_client()
if self._http_client is not None:
try:
await self._http_client.aclose()
except (httpx.HTTPError, OSError) as exc:
await self._logger.warn(
"QQBot httpx client close failed during unload",
error=str(exc),
)
self._http_client = None
self._started = False
await self._logger.info("QQBot plugin unloaded")
async def onReconfigure(
self,
*,
old_config: dict[str, Any] | None = None,
new_config: dict[str, Any],
) -> None:
"""配置热更新:校验不可热更新字段。
- 不可热更新键(``hot_reloadable=False``,如 ``app_id`` /
``app_secret`` / ``intents`` / ``api_base`` / ``gateway_url`` /
``sandbox``)变更抛 ``ConfigRestartRequiredError``FR-37
- ``old_config`` 为 ``None``(首次调用或无基线)时仅记录日志,不做对比。
- 校验失败由宿主回滚,本方法无副作用(不缓存基线,基线由宿主维护)。
"""
if old_config is None:
await self._logger.info("QQBot plugin initial config baseline set")
return
for key in self._restart_required_keys:
new_value = new_config.get(key)
old_value = old_config.get(key)
if new_value != old_value:
raise ConfigRestartRequiredError(key=key) from None
await self._logger.info("QQBot plugin reconfigured")
async def onFail(self, error: str) -> None:
"""失败清理不抛异常不阻断降级流程FR-36
logger 不可用时回退 ``sys.stderr.write``,确保失败信息不丢失。
不抛异常以遵守 FR-36「不阻断降级流程」约束。
"""
self._started = False
try:
await self._logger.error("QQBot plugin failed", error=error)
except Exception as exc:
sys.stderr.write(f"QQBot plugin failed: {error}\nLogger error: {exc}\n")
__all__ = ["QQBotLifecycleHandler"]