1. 优化HTTP客户端关闭异常捕获范围 2. 重构事件工具方法抽取公共模块 3. 新增频控缓存常量与身份缓存配置 4. 完善适配器依赖注入与配置读取 5. 实现被动消息频控与令牌刷新串行化 6. 修复会话、状态、目录等适配器逻辑 7. 优化探测适配器账户获取逻辑 8. 完善凭据轮换与生命周期钩子 9. 优化WebSocket连接处理与心跳机制
178 lines
7.1 KiB
Python
178 lines
7.1 KiB
Python
"""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"]
|