ForcePilot/backend/package/yuxi/channels/plugins/wechat_ilink/lifecycle.py

184 lines
7.2 KiB
Python
Raw Normal View History

"""微信 iLinkClawBot渠道插件生命周期钩子处理。
实现 ``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"]