Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 77 additions & 73 deletions plugins/platforms/feishu/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,45 +85,35 @@
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
Expand Down Expand Up @@ -1358,36 +1348,38 @@ def _configure_with_overrides(conf: Any) -> Any:
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,
Expand All @@ -1414,10 +1406,22 @@ def _import():
"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):
Expand Down Expand Up @@ -1715,9 +1719,6 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
# 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
Expand All @@ -1732,6 +1733,9 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
"[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
Expand Down Expand Up @@ -5417,7 +5421,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: pip install 'hermes-agent[feishu]'"}

media_files = media_files or []
Expand Down
55 changes: 55 additions & 0 deletions tests/gateway/test_feishu_lazy_import.py
Original file line number Diff line number Diff line change
@@ -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)