本次提交包含多项代码优化与规范修正: 1. 文档与注释优化:修正注释术语、补充注解与FR编号 2. 代码格式调整:统一空格、换行与缩进规范 3. 类型与接口完善:补充__all__导出、修正返回类型注解 4. 错误处理增强:新增领域错误类与校验逻辑 5. 依赖与导入调整:修复路径引用、统一时区导入 6. 协议与契约更新:完善接口文档与一致性注解
184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
"""微信 iLink(ClawBot)渠道插件生命周期钩子处理。
|
||
|
||
实现 ``LifecycleHookHandler`` Protocol,管理插件级资源(httpx 连接池),
|
||
响应宿主生命周期状态变迁。
|
||
|
||
设计要点:
|
||
- LifecycleHandler 为单实例(非 per-account),可持有 mutable 状态用于
|
||
资源管理(与适配器的 INV-5 无 mutable 状态约束不同)。
|
||
- httpx 连接池在 ``onInit`` 创建并注入 ``ILinkClient``,``onUnload`` 解除
|
||
引用后关闭;连接数上限对齐 manifest.json 中
|
||
``resource_quota.max_connections=10``。
|
||
- 长轮询拉取由 ``StreamWorker`` 通过 ``PullerAdapter`` 统一管理,
|
||
``LifecycleHandler`` 不持有长轮询句柄。
|
||
- ``onReconfigure`` 校验不可热更新字段,变更时抛 ``ConfigRestartRequiredError``,
|
||
校验失败保持原基线以支持宿主回滚。
|
||
- ``onFail`` 不抛异常、不静默吞错;logger 失败时回退到 ``sys.stderr.write``,
|
||
不阻断 FR-36 优雅降级流程。
|
||
|
||
依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx,
|
||
不污染框架层。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import httpx
|
||
|
||
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
|
||
|
||
if TYPE_CHECKING:
|
||
from .ilink_client import ILinkClient
|
||
|
||
# 不可热更新的配置键(manifest.json 中 hot_reloadable=false)
|
||
_RESTART_REQUIRED_KEYS: tuple[str, ...] = (
|
||
"bot_token",
|
||
"ilink_bot_id",
|
||
"ilink_user_id",
|
||
"baseurl",
|
||
)
|
||
|
||
# httpx 连接池配置(对齐 manifest.json resource_quota: max_connections=10)
|
||
_HTTP_TIMEOUT_SECONDS = 30.0
|
||
_HTTP_MAX_CONNECTIONS = 10
|
||
_HTTP_MAX_KEEPALIVE_CONNECTIONS = 5
|
||
|
||
|
||
class WeChatILinkLifecycleHandler:
|
||
"""微信 iLink 渠道插件生命周期钩子处理。
|
||
|
||
单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池,通过
|
||
``attach_http_client`` 注入 ``ILinkClient`` 供其复用(避免每请求新建
|
||
TCP 连接);``_last_config`` 缓存上一次有效配置,供 ``onReconfigure``
|
||
校验与回滚。长轮询连接由 ``StreamWorker`` 管理,本处理器不持有拉取句柄。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
config_port: ConfigPort,
|
||
logger_port: LoggerPort,
|
||
cache_port: CachePort,
|
||
client: ILinkClient | 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
|
||
self._last_config: dict[str, Any] | None = None
|
||
|
||
def attach_client(self, client: ILinkClient) -> None:
|
||
"""注入 ``ILinkClient`` 引用,供 ``onInit`` 注入连接池。
|
||
|
||
由 ``entry.channel_entry`` 在实例化 client 后调用(解决 client 与
|
||
handler 的循环依赖:handler 需 client 注入连接池,client 需 handler
|
||
创建的连接池)。
|
||
"""
|
||
self._client = client
|
||
|
||
# ------------------------------------------------------------------
|
||
# LifecycleHookHandler Protocol 实现
|
||
# ------------------------------------------------------------------
|
||
|
||
async def onInit(self) -> None:
|
||
"""初始化资源:创建 httpx 连接池并注入 ILinkClient。"""
|
||
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("WeChat iLink plugin initialized")
|
||
|
||
async def onStart(self) -> None:
|
||
"""标记插件就绪。"""
|
||
self._started = True
|
||
await self._logger.info("WeChat iLink plugin started")
|
||
|
||
async def onStop(self) -> None:
|
||
"""停止插件:标记未就绪,等待在途请求完成。"""
|
||
await self._logger.info(
|
||
"WeChat iLink plugin stopping, waiting for in-flight requests",
|
||
)
|
||
self._started = False
|
||
|
||
async def onPause(self) -> None:
|
||
"""暂停插件:停止接收新请求。"""
|
||
self._started = False
|
||
await self._logger.info("WeChat iLink plugin paused")
|
||
|
||
async def onResume(self) -> None:
|
||
"""恢复插件:重新接收请求。"""
|
||
self._started = True
|
||
await self._logger.info("WeChat iLink plugin resumed")
|
||
|
||
async def onUnload(self) -> None:
|
||
"""释放所有资源:解除 client 引用后关闭 httpx 连接池。
|
||
|
||
长轮询连接由 ``StreamWorker`` 在宿主关停时首先停止,
|
||
本方法不再处理拉取句柄清理。
|
||
"""
|
||
# 先解除 client 对连接池的引用,避免关闭后悬挂调用
|
||
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 Exception as exc:
|
||
await self._logger.warn(
|
||
"WeChat iLink httpx client close failed during unload",
|
||
error=str(exc),
|
||
)
|
||
self._http_client = None
|
||
|
||
self._started = False
|
||
await self._logger.info("WeChat iLink plugin unloaded")
|
||
|
||
async def onReconfigure(self, config: dict[str, Any]) -> None:
|
||
"""配置热更新:校验不可热更新字段,接受则缓存为新基线。
|
||
|
||
- ``bot_token`` / ``ilink_bot_id`` / ``ilink_user_id`` / ``baseurl``
|
||
变更抛 ``ConfigRestartRequiredError``(FR-37)。
|
||
- 首次调用(``_last_config`` 为 None)仅建立基线,不做对比。
|
||
- 校验通过后更新 ``_last_config``;校验失败保持原基线,由宿主回滚。
|
||
"""
|
||
if self._last_config is None:
|
||
self._last_config = dict(config)
|
||
await self._logger.info(
|
||
"WeChat iLink plugin initial config baseline set",
|
||
)
|
||
return
|
||
|
||
for key in _RESTART_REQUIRED_KEYS:
|
||
new_value = config.get(key)
|
||
old_value = self._last_config.get(key)
|
||
if new_value != old_value:
|
||
raise ConfigRestartRequiredError(key=key) from None
|
||
|
||
self._last_config = dict(config)
|
||
await self._logger.info("WeChat iLink plugin reconfigured")
|
||
|
||
async def onFail(self, error: str) -> None:
|
||
"""失败清理:不抛异常,不阻断降级流程(FR-36)。
|
||
|
||
logger 不可用时回退到 ``sys.stderr.write``,确保失败信息不丢失、
|
||
不被静默吞掉,同时不向上抛出异常以保证降级流程继续。
|
||
"""
|
||
self._started = False
|
||
try:
|
||
await self._logger.error("WeChat iLink plugin failed", error=error)
|
||
except Exception as exc:
|
||
sys.stderr.write(f"WeChat iLink plugin failed (logger unavailable): {error}\nlogger_error={exc!r}\n")
|
||
|
||
|
||
__all__ = ["WeChatILinkLifecycleHandler"]
|