From b51c4e6a7840a5a4ff22e16f51550dfda0edc255 Mon Sep 17 00:00:00 2001 From: baau <1347825413@qq.com> Date: Fri, 17 Jul 2026 16:35:52 +0800 Subject: [PATCH] fix(feishu): defer the lark_oapi import off the startup path Salvage of #57657, ported onto the plugin layout (the adapter moved from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py since the PR's base). lark_oapi takes seconds to import and holds the GIL doing it; the module-level import made every gateway boot pay that cost even with Feishu unconfigured. - _load_lark_oapi() with double-checked locking binds the SDK globals on first use; connect() and _standalone_send() call it via asyncio.to_thread so the loop never blocks on the import. - probe_bot() also calls _load_lark_oapi() (sync context) so the SDK probe path is preserved rather than silently degrading to the HTTP fallback before a first connect. - check_feishu_requirements() is install-only and no longer rebinds globals; test_feishu.py gets a setUpModule that binds them eagerly for tests that inject fake clients. Includes the dedicated lazy-import test file (check-does-not-import, connect-loads-on-worker-thread). --- plugins/platforms/feishu/adapter.py | 155 ++++++++++++----------- tests/gateway/test_feishu.py | 12 ++ tests/gateway/test_feishu_lazy_import.py | 55 ++++++++ 3 files changed, 148 insertions(+), 74 deletions(-) create mode 100644 tests/gateway/test_feishu_lazy_import.py diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index c21bb42e9011c..68875d998628a 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -85,45 +85,35 @@ try: except ImportError: websockets = None # type: ignore[assignment] -try: - import lark_oapi as lark - from lark_oapi.api.application.v6 import GetApplicationRequest - from lark_oapi.api.im.v1 import ( - CreateFileRequest, - CreateFileRequestBody, - CreateImageRequest, - CreateImageRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, - GetChatRequest, - GetMessageRequest, - GetMessageResourceRequest, - P2ImMessageMessageReadV1, - ReplyMessageRequest, - ReplyMessageRequestBody, - UpdateMessageRequest, - UpdateMessageRequestBody, - ) - from lark_oapi.core import AccessTokenType, HttpMethod - from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN - from lark_oapi.core.model import BaseRequest - from lark_oapi.event.callback.model.p2_card_action_trigger import ( - CallBackCard, - P2CardActionTriggerResponse, - ) - from lark_oapi.event.dispatcher_handler import EventDispatcherHandler - from lark_oapi.ws import Client as FeishuWSClient - - FEISHU_AVAILABLE = True -except ImportError: - FEISHU_AVAILABLE = False - lark = None # type: ignore[assignment] - CallBackCard = None # type: ignore[assignment] - P2CardActionTriggerResponse = None # type: ignore[assignment] - EventDispatcherHandler = None # type: ignore[assignment] - FeishuWSClient = None # type: ignore[assignment] - FEISHU_DOMAIN = None # type: ignore[assignment] - LARK_DOMAIN = None # type: ignore[assignment] +# lark_oapi takes a noticeable amount of time to import. Keep the gateway +# configuration path responsive by importing it only when Feishu connects. +lark = None # type: ignore[assignment] +GetApplicationRequest = None # type: ignore[assignment] +CreateFileRequest = None # type: ignore[assignment] +CreateFileRequestBody = None # type: ignore[assignment] +CreateImageRequest = None # type: ignore[assignment] +CreateImageRequestBody = None # type: ignore[assignment] +CreateMessageRequest = None # type: ignore[assignment] +CreateMessageRequestBody = None # type: ignore[assignment] +GetChatRequest = None # type: ignore[assignment] +GetMessageRequest = None # type: ignore[assignment] +GetMessageResourceRequest = None # type: ignore[assignment] +P2ImMessageMessageReadV1 = None # type: ignore[assignment] +ReplyMessageRequest = None # type: ignore[assignment] +ReplyMessageRequestBody = None # type: ignore[assignment] +UpdateMessageRequest = None # type: ignore[assignment] +UpdateMessageRequestBody = None # type: ignore[assignment] +AccessTokenType = None # type: ignore[assignment] +HttpMethod = None # type: ignore[assignment] +FEISHU_DOMAIN = None # type: ignore[assignment] +LARK_DOMAIN = None # type: ignore[assignment] +BaseRequest = None # type: ignore[assignment] +CallBackCard = None # type: ignore[assignment] +P2CardActionTriggerResponse = None # type: ignore[assignment] +EventDispatcherHandler = None # type: ignore[assignment] +FeishuWSClient = None # type: ignore[assignment] +FEISHU_AVAILABLE = False +_lark_import_lock = threading.Lock() FEISHU_WEBSOCKET_AVAILABLE = websockets is not None FEISHU_WEBHOOK_AVAILABLE = aiohttp is not None @@ -1395,36 +1385,38 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: adapter._ws_thread_loop = None -def check_feishu_requirements() -> bool: - """Check if Feishu/Lark dependencies are available. - - Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")`` - on first call if not present. Rebinds all module-level globals on success. - """ +def _load_lark_oapi() -> bool: + """Import and bind the Feishu SDK after an explicit connection request.""" if FEISHU_AVAILABLE: return True - def _import(): - import lark_oapi as lark - from lark_oapi.api.application.v6 import GetApplicationRequest - from lark_oapi.api.im.v1 import ( - CreateFileRequest, CreateFileRequestBody, - CreateImageRequest, CreateImageRequestBody, - CreateMessageRequest, CreateMessageRequestBody, - GetChatRequest, GetMessageRequest, GetMessageResourceRequest, - P2ImMessageMessageReadV1, - ReplyMessageRequest, ReplyMessageRequestBody, - UpdateMessageRequest, UpdateMessageRequestBody, - ) - from lark_oapi.core import AccessTokenType, HttpMethod - from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN - from lark_oapi.core.model import BaseRequest - from lark_oapi.event.callback.model.p2_card_action_trigger import ( - CallBackCard, P2CardActionTriggerResponse, - ) - from lark_oapi.event.dispatcher_handler import EventDispatcherHandler - from lark_oapi.ws import Client as FeishuWSClient - return { + with _lark_import_lock: + if FEISHU_AVAILABLE: + return True + try: + import lark_oapi as lark + from lark_oapi.api.application.v6 import GetApplicationRequest + from lark_oapi.api.im.v1 import ( + CreateFileRequest, CreateFileRequestBody, + CreateImageRequest, CreateImageRequestBody, + CreateMessageRequest, CreateMessageRequestBody, + GetChatRequest, GetMessageRequest, GetMessageResourceRequest, + P2ImMessageMessageReadV1, + ReplyMessageRequest, ReplyMessageRequestBody, + UpdateMessageRequest, UpdateMessageRequestBody, + ) + from lark_oapi.core import AccessTokenType, HttpMethod + from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN + from lark_oapi.core.model import BaseRequest + from lark_oapi.event.callback.model.p2_card_action_trigger import ( + CallBackCard, P2CardActionTriggerResponse, + ) + from lark_oapi.event.dispatcher_handler import EventDispatcherHandler + from lark_oapi.ws import Client as FeishuWSClient + except ImportError: + return False + + globals().update({ "lark": lark, "GetApplicationRequest": GetApplicationRequest, "CreateFileRequest": CreateFileRequest, @@ -1451,10 +1443,22 @@ def check_feishu_requirements() -> bool: "EventDispatcherHandler": EventDispatcherHandler, "FeishuWSClient": FeishuWSClient, "FEISHU_AVAILABLE": True, - } + }) + return True - from tools.lazy_deps import ensure_and_bind - return ensure_and_bind("platform.feishu", _import, globals(), prompt=False) + +def check_feishu_requirements() -> bool: + """Ensure Feishu dependencies are installed without importing the SDK.""" + if FEISHU_AVAILABLE: + return True + + from tools.lazy_deps import ensure + + try: + ensure("platform.feishu", prompt=False) + return True + except Exception: + return False class FeishuAdapter(BasePlatformAdapter): @@ -1752,9 +1756,6 @@ class FeishuAdapter(BasePlatformAdapter): # A fresh connect (or reconnect) re-arms the SDK executor after a prior # disconnect set the closing flag. self._sdk_executor_closing = False - if not FEISHU_AVAILABLE: - logger.error("[Feishu] lark-oapi not installed") - return False if not self._app_id or not self._app_secret: logger.error("[Feishu] FEISHU_APP_ID or FEISHU_APP_SECRET not set") return False @@ -1769,6 +1770,9 @@ class FeishuAdapter(BasePlatformAdapter): "[Feishu] Webhook mode requires FEISHU_VERIFICATION_TOKEN or FEISHU_ENCRYPT_KEY." ) return False + if not await asyncio.to_thread(_load_lark_oapi): + logger.error("[Feishu] lark-oapi not installed") + return False try: self._app_lock_identity = self._app_id @@ -5410,7 +5414,10 @@ def probe_bot(app_id: str, app_secret: str, domain: str) -> Optional[dict]: Note: ``bot_open_id`` here is the bot's app-scoped open_id — the same ID that Feishu puts in @mention payloads. It is NOT the app_id. """ - if FEISHU_AVAILABLE: + # The SDK import is deferred until connect(); onboarding runs before any + # connect, so load it here to keep the SDK probe path reachable rather + # than silently degrading every setup run to the HTTP fallback. + if _load_lark_oapi(): return _probe_bot_sdk(app_id, app_secret, domain) return _probe_bot_http(app_id, app_secret, domain) @@ -5598,7 +5605,7 @@ async def _standalone_send( FeishuAdapter, hydrates its lark client, and sends text + native media (images, video, voice, documents). Replaces the legacy _send_feishu helper. """ - if not FEISHU_AVAILABLE: + if not await asyncio.to_thread(_load_lark_oapi): return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."} media_files = media_files or [] diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index a923ea5f5d46e..0d861812c5a22 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -22,6 +22,18 @@ except ImportError: _HAS_LARK_OAPI = False +def setUpModule(): + """Bind the lark SDK globals before tests that inject a mock ``_client``. + + The adapter defers the lark import to ``connect()`` (``_load_lark_oapi``), + so tests that skip connect() and set ``adapter._client`` directly would + otherwise see the request-builder globals still bound to None. + """ + if _HAS_LARK_OAPI: + from plugins.platforms.feishu.adapter import _load_lark_oapi + _load_lark_oapi() + + class _FakeRequestContent: def __init__(self, body: bytes): self.body = body diff --git a/tests/gateway/test_feishu_lazy_import.py b/tests/gateway/test_feishu_lazy_import.py new file mode 100644 index 0000000000000..5f7064320514d --- /dev/null +++ b/tests/gateway/test_feishu_lazy_import.py @@ -0,0 +1,55 @@ +"""Regression coverage for deferred Feishu SDK loading.""" + +import asyncio +import os +import tempfile +from unittest.mock import AsyncMock, patch + + +def _feishu_adapter_module(): + """Import the adapter with the Windows app-data root available in CI.""" + with patch.dict(os.environ, {"LOCALAPPDATA": tempfile.gettempdir()}): + from plugins.platforms.feishu import adapter + + return adapter + + +def test_configured_feishu_dependency_check_does_not_load_sdk(): + """Gateway configuration can validate Feishu without importing its SDK.""" + feishu_adapter = _feishu_adapter_module() + + with ( + patch.object(feishu_adapter, "FEISHU_AVAILABLE", False), + patch("tools.lazy_deps.ensure", autospec=True) as ensure, + ): + assert feishu_adapter.check_feishu_requirements() is True + assert feishu_adapter.FEISHU_AVAILABLE is False + + ensure.assert_called_once_with("platform.feishu", prompt=False) + + +def test_feishu_connect_loads_sdk_on_worker_thread(): + """The first SDK import is deferred until a configured adapter connects.""" + from gateway.config import PlatformConfig + feishu_adapter = _feishu_adapter_module() + + adapter = feishu_adapter.FeishuAdapter( + PlatformConfig( + extra={ + "app_id": "cli_test", + "app_secret": "secret_test", + "connection_mode": "websocket", + } + ) + ) + + with ( + patch.object(feishu_adapter, "FEISHU_AVAILABLE", False), + patch.object(feishu_adapter, "_load_lark_oapi", return_value=True) as load_sdk, + patch.object(feishu_adapter.asyncio, "to_thread", new_callable=AsyncMock, return_value=True) as to_thread, + patch.object(adapter, "_connect_with_retry", new_callable=AsyncMock), + patch.object(feishu_adapter, "acquire_scoped_lock", return_value=(True, {})), + ): + assert asyncio.run(adapter.connect()) is True + + to_thread.assert_awaited_once_with(load_sdk)