fix(feishu): lazy-load lark sdk during gateway startup

This commit is contained in:
chengyongru 2026-06-10 17:55:10 +08:00 committed by Xubin Ren
parent 7186039be1
commit 5d7f2e60c2
2 changed files with 91 additions and 8 deletions

View File

@ -1,5 +1,7 @@
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" """Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
from __future__ import annotations
import asyncio import asyncio
import importlib.util import importlib.util
import json import json
@ -11,10 +13,8 @@ import uuid
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass 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 pydantic import Field
from nanobot.bus.events import OutboundMessage 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.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging 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 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 # Message type display mapping
MSG_TYPE_MAP = { MSG_TYPE_MAP = {
"image": "[image]", "image": "[image]",
@ -297,13 +331,11 @@ class FeishuChannel(BaseChannel):
return FeishuConfig().model_dump(by_alias=True) return FeishuConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config: Any, bus: MessageBus):
import lark_oapi as lark
if isinstance(config, dict): if isinstance(config, dict):
config = FeishuConfig.model_validate(config) config = FeishuConfig.model_validate(config)
super().__init__(config, bus) super().__init__(config, bus)
self.config: FeishuConfig = config self.config: FeishuConfig = config
self._client: lark.Client = None self._client: Any = None
self._ws_client: Any = None self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None self._ws_thread: threading.Thread | None = None
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache 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") self.logger.error("app_id and app_secret not configured")
return return
import lark_oapi as lark lark, feishu_domain, lark_domain = await asyncio.to_thread(_load_lark_runtime)
redirect_lib_logging("Lark") redirect_lib_logging("Lark")
@ -337,7 +369,7 @@ class FeishuChannel(BaseChannel):
self._loop = asyncio.get_running_loop() self._loop = asyncio.get_running_loop()
# Create Lark client for sending messages # 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 = ( self._client = (
lark.Client.builder() lark.Client.builder()
.app_id(self.config.app_id) .app_id(self.config.app_id)
@ -397,6 +429,7 @@ class FeishuChannel(BaseChannel):
import lark_oapi.ws.client as _lark_ws_client import lark_oapi.ws.client as _lark_ws_client
previous_loop = getattr(_lark_ws_client, "loop", None)
ws_loop = asyncio.new_event_loop() ws_loop = asyncio.new_event_loop()
asyncio.set_event_loop(ws_loop) asyncio.set_event_loop(ws_loop)
# Patch the module-level loop used by lark's ws Client.start() # Patch the module-level loop used by lark's ws Client.start()
@ -410,6 +443,10 @@ class FeishuChannel(BaseChannel):
if self._running: if self._running:
time.sleep(5) time.sleep(5)
finally: 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() ws_loop.close()
self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread = threading.Thread(target=run_ws, daemon=True)

View File

@ -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"