2026-07-02 03:22:12 +08:00
|
|
|
|
"""飞书渠道插件生命周期钩子处理。
|
|
|
|
|
|
|
|
|
|
|
|
实现 ``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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
self._restart_required_keys = tuple(field.key for field in config_schema if not field.hot_reloadable)
|
2026-07-02 03:22:12 +08:00
|
|
|
|
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:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
sys.stderr.write(f"Feishu plugin failed: {error}\nLogger error: {exc}\n")
|
2026-07-02 03:22:12 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = ["FeishuLifecycleHandler"]
|