diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 060ba2bb5..381554347 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1,5 +1,7 @@ """Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" +from __future__ import annotations + import asyncio import importlib.util import json @@ -11,10 +13,8 @@ import uuid from collections import OrderedDict from contextlib import suppress from dataclasses import dataclass -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal -from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1 -from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN from pydantic import Field from nanobot.bus.events import OutboundMessage @@ -25,8 +25,42 @@ from nanobot.config.schema import Base from nanobot.utils.helpers import safe_filename from nanobot.utils.logging_bridge import redirect_lib_logging +if TYPE_CHECKING: + from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1 + FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None + +def _load_lark_runtime() -> tuple[Any, str, str]: + """Import the heavy Feishu SDK lazily. + + lark_oapi imports a large generated API surface at module import time, so + keep it out of channel discovery and constructor paths. + """ + import sys + + ws_client_already_imported = "lark_oapi.ws.client" in sys.modules + import lark_oapi as lark + import lark_oapi.ws.client as lark_ws_client + from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN + + if ( + not ws_client_already_imported + and threading.current_thread() is not threading.main_thread() + ): + import_loop = getattr(lark_ws_client, "loop", None) + if ( + import_loop is not None + and not import_loop.is_running() + and not import_loop.is_closed() + ): + import_loop.close() + lark_ws_client.loop = None + with suppress(Exception): + asyncio.set_event_loop(None) + + return lark, FEISHU_DOMAIN, LARK_DOMAIN + # Message type display mapping MSG_TYPE_MAP = { "image": "[image]", @@ -297,13 +331,11 @@ class FeishuChannel(BaseChannel): return FeishuConfig().model_dump(by_alias=True) def __init__(self, config: Any, bus: MessageBus): - import lark_oapi as lark - if isinstance(config, dict): config = FeishuConfig.model_validate(config) super().__init__(config, bus) self.config: FeishuConfig = config - self._client: lark.Client = None + self._client: Any = None self._ws_client: Any = None self._ws_thread: threading.Thread | None = None self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache @@ -329,7 +361,7 @@ class FeishuChannel(BaseChannel): self.logger.error("app_id and app_secret not configured") return - import lark_oapi as lark + lark, feishu_domain, lark_domain = await asyncio.to_thread(_load_lark_runtime) redirect_lib_logging("Lark") @@ -337,7 +369,7 @@ class FeishuChannel(BaseChannel): self._loop = asyncio.get_running_loop() # Create Lark client for sending messages - domain = LARK_DOMAIN if self.config.domain == "lark" else FEISHU_DOMAIN + domain = lark_domain if self.config.domain == "lark" else feishu_domain self._client = ( lark.Client.builder() .app_id(self.config.app_id) @@ -397,6 +429,7 @@ class FeishuChannel(BaseChannel): import lark_oapi.ws.client as _lark_ws_client + previous_loop = getattr(_lark_ws_client, "loop", None) ws_loop = asyncio.new_event_loop() asyncio.set_event_loop(ws_loop) # Patch the module-level loop used by lark's ws Client.start() @@ -410,6 +443,10 @@ class FeishuChannel(BaseChannel): if self._running: time.sleep(5) finally: + if getattr(_lark_ws_client, "loop", None) is ws_loop: + _lark_ws_client.loop = previous_loop + with suppress(Exception): + asyncio.set_event_loop(None) ws_loop.close() self._ws_thread = threading.Thread(target=run_ws, daemon=True) diff --git a/tests/channels/test_feishu_lazy_import.py b/tests/channels/test_feishu_lazy_import.py new file mode 100644 index 000000000..d43c39ebb --- /dev/null +++ b/tests/channels/test_feishu_lazy_import.py @@ -0,0 +1,46 @@ +import subprocess +import sys + + +def _run_import_probe(source: str) -> str: + proc = subprocess.run( + [sys.executable, "-c", source], + check=True, + capture_output=True, + text=True, + ) + return proc.stdout.strip() + + +def test_feishu_module_import_does_not_import_lark_oapi(): + out = _run_import_probe( + "import sys; import nanobot.channels.feishu; print('lark_oapi' in sys.modules)" + ) + + assert out == "False" + + +def test_feishu_channel_constructor_does_not_import_lark_oapi(): + out = _run_import_probe( + "import sys; " + "from nanobot.bus.queue import MessageBus; " + "from nanobot.channels.feishu import FeishuChannel; " + "FeishuChannel({'enabled': True}, MessageBus()); " + "print('lark_oapi' in sys.modules)" + ) + + assert out == "False" + + +def test_lark_runtime_thread_import_clears_sdk_import_loop(): + out = _run_import_probe( + "import asyncio\n" + "from nanobot.channels.feishu import _load_lark_runtime\n" + "async def main():\n" + " await asyncio.to_thread(_load_lark_runtime)\n" + " import lark_oapi.ws.client as ws\n" + " print(getattr(ws, 'loop', 'sentinel') is None)\n" + "asyncio.run(main())" + ) + + assert out == "True"