"""飞书渠道插件生命周期钩子处理。 实现 ``LifecycleHookHandler`` Protocol,管理插件级资源(httpx 连接池), 响应宿主生命周期状态变迁。 设计要点: - LifecycleHandler 为单实例(非 per-account),可持有 mutable 状态用于 资源管理(与适配器的 INV-5 无 mutable 状态约束不同)。 - httpx 连接池在 ``onInit`` 创建,``onUnload`` 关闭。 - WebSocket 长连接由 ``StreamWorker`` 通过 ``FeishuStreamConnectorAdapter`` 统一管理,``LifecycleHandler`` 不再持有 WS 客户端句柄。 - ``onReconfigure`` 从配置 schema 派生不可热更新键列表 (``hot_reloadable=False``),变更时抛 ``ConfigRestartRequiredError``。 - ``onFail`` 不抛异常,不阻断降级流程;logger 不可用时回退 ``stderr``。 依赖方向:仅 import ``yuxi.channels.contract.*`` + 标准库 + httpx, 不污染框架层。 """ from __future__ import annotations import sys from typing import 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 # 默认不可热更新键(config_schema 未注入时的回退,与 manifest.json # config_schema 中 hot_reloadable=False 的字段保持一致) _DEFAULT_RESTART_REQUIRED_KEYS: tuple[str, ...] = ( "app_id", "app_secret", "event_mode", "webhook_url", ) # httpx 连接池配置 _HTTP_TIMEOUT_SECONDS = 30.0 _HTTP_MAX_CONNECTIONS = 50 _HTTP_MAX_KEEPALIVE_CONNECTIONS = 10 class FeishuLifecycleHandler: """飞书渠道插件生命周期钩子处理。 单实例,管理插件级资源生命周期。``_http_client`` 为共享连接池, ``_last_config`` 缓存上一次有效配置,供 ``onReconfigure`` 校验与回滚。 不可热更新键列表从配置 schema 派生(``hot_reloadable=False``),schema 未注入时回退到 ``_DEFAULT_RESTART_REQUIRED_KEYS``。WebSocket 连接由 ``StreamWorker`` 管理,本处理器不持有 WS 句柄。 """ def __init__( self, config_port: ConfigPort, logger_port: LoggerPort, cache_port: CachePort, config_schema: tuple[ConfigField, ...] | None = None, ) -> None: self._config = config_port self._logger = logger_port self._cache = cache_port self._http_client: httpx.AsyncClient | None = None self._started: bool = False self._last_config: dict[str, Any] | None = None # 从配置 schema 派生不可热更新键(hot_reloadable=False),避免 # 硬编码与 manifest config_schema 不一致 if config_schema is not None: self._restart_required_keys = tuple(field.key for field in config_schema if not field.hot_reloadable) else: self._restart_required_keys = _DEFAULT_RESTART_REQUIRED_KEYS # ------------------------------------------------------------------ # LifecycleHookHandler Protocol 实现 # ------------------------------------------------------------------ async def onInit(self) -> None: """初始化资源:创建 httpx 连接池。""" self._http_client = httpx.AsyncClient( timeout=_HTTP_TIMEOUT_SECONDS, limits=httpx.Limits( max_connections=_HTTP_MAX_CONNECTIONS, max_keepalive_connections=_HTTP_MAX_KEEPALIVE_CONNECTIONS, ), ) await self._logger.info("Feishu plugin initialized") async def onStart(self) -> None: """标记插件就绪。""" self._started = True await self._logger.info("Feishu plugin started") async def onStop(self) -> None: """停止插件:标记未就绪,等待在途请求完成。""" await self._logger.info( "Feishu plugin stopping, waiting for in-flight requests", ) self._started = False async def onPause(self) -> None: """暂停插件:停止接收新请求。""" self._started = False await self._logger.info("Feishu plugin paused") async def onResume(self) -> None: """恢复插件:重新接收请求。""" self._started = True await self._logger.info("Feishu plugin resumed") async def onUnload(self) -> None: """释放所有资源:关闭 httpx 连接池。 WebSocket 连接由 ``TransportManager.stop()`` 在宿主关停时首先关闭, 本方法不再处理 WS 客户端清理。 """ if self._http_client is not None: try: await self._http_client.aclose() except Exception as exc: await self._logger.warn( "Feishu httpx client close failed during unload", error=str(exc), ) self._http_client = None self._started = False await self._logger.info("Feishu plugin unloaded") async def onReconfigure(self, config: dict[str, Any]) -> None: """配置热更新:校验不可热更新字段,接受则缓存为新基线。 - 不可热更新键(``hot_reloadable=False``,如 ``app_id`` / ``app_secret`` / ``event_mode`` / ``webhook_url``)变更抛 ``ConfigRestartRequiredError``(FR-37)。 - 首次调用(``_last_config`` 为 None)仅建立基线,不做对比。 - 校验通过后更新 ``_last_config``;校验失败保持原基线,由宿主回滚。 """ if self._last_config is None: self._last_config = dict(config) await self._logger.info("Feishu plugin initial config baseline set") return for key in self._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("Feishu 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("Feishu plugin failed", error=error) except Exception as exc: sys.stderr.write(f"Feishu plugin failed: {error}\nLogger error: {exc}\n") __all__ = ["FeishuLifecycleHandler"]