fix(gateway): complete lazy-install rebind for slack/feishu/matrix + add ensure_and_bind helper (#25038)

Fixes #25028.

The lazy-install hooks added in #25014 installed packages correctly but
failed to rebind module-level globals after install:

- Slack: missing aiohttp rebind → NameError on file uploads
- Feishu: none of the ~25 lark_oapi symbols rebound → TypeError on
  adapter instantiation
- Matrix: mautrix.types enums stayed as stubs → mismatched values at
  runtime

Introduces tools.lazy_deps.ensure_and_bind() — a DRY helper that
combines ensure() + importer-callable + globals().update(). This
eliminates the error-prone pattern of manually listing every global
that needs updating after lazy-install. Each platform adapter now
defines a single _import() function returning all bindings.

Also fixes: pyproject.toml [slack] extra was missing aiohttp (needed
by slack-bolt's async path).
This commit is contained in:
Siddharth Balyan 2026-05-14 10:41:46 +05:30 committed by GitHub
parent 52521c937a
commit d898e0eb7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 149 additions and 39 deletions

View file

@ -1346,22 +1346,62 @@ 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.
on first call if not present. Rebinds all module-level globals on success.
"""
global FEISHU_AVAILABLE
if FEISHU_AVAILABLE:
return True
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("platform.feishu", prompt=False)
except Exception:
return False
try:
import lark_oapi # noqa: F401
except ImportError:
return False
FEISHU_AVAILABLE = True
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 {
"lark": lark,
"GetApplicationRequest": GetApplicationRequest,
"CreateFileRequest": CreateFileRequest,
"CreateFileRequestBody": CreateFileRequestBody,
"CreateImageRequest": CreateImageRequest,
"CreateImageRequestBody": CreateImageRequestBody,
"CreateMessageRequest": CreateMessageRequest,
"CreateMessageRequestBody": CreateMessageRequestBody,
"GetChatRequest": GetChatRequest,
"GetMessageRequest": GetMessageRequest,
"GetMessageResourceRequest": GetMessageResourceRequest,
"P2ImMessageMessageReadV1": P2ImMessageMessageReadV1,
"ReplyMessageRequest": ReplyMessageRequest,
"ReplyMessageRequestBody": ReplyMessageRequestBody,
"UpdateMessageRequest": UpdateMessageRequest,
"UpdateMessageRequestBody": UpdateMessageRequestBody,
"AccessTokenType": AccessTokenType,
"HttpMethod": HttpMethod,
"FEISHU_DOMAIN": FEISHU_DOMAIN,
"LARK_DOMAIN": LARK_DOMAIN,
"BaseRequest": BaseRequest,
"CallBackCard": CallBackCard,
"P2CardActionTriggerResponse": P2CardActionTriggerResponse,
"EventDispatcherHandler": EventDispatcherHandler,
"FeishuWSClient": FeishuWSClient,
"FEISHU_AVAILABLE": True,
}
from tools.lazy_deps import ensure_and_bind
return ensure_and_bind("platform.feishu", _import, globals(), prompt=False)
class FeishuAdapter(BasePlatformAdapter):

View file

@ -227,7 +227,7 @@ def check_matrix_requirements() -> bool:
"""Return True if the Matrix adapter can be used.
Lazy-installs mautrix via ``tools.lazy_deps.ensure("platform.matrix")``
on first call if not present.
on first call if not present. Rebinds all module-level type globals on success.
"""
token = os.getenv("MATRIX_ACCESS_TOKEN", "")
password = os.getenv("MATRIX_PASSWORD", "")
@ -242,11 +242,27 @@ def check_matrix_requirements() -> bool:
try:
import mautrix # noqa: F401
except ImportError:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("platform.matrix", prompt=False)
import mautrix # noqa: F401, F811
except Exception:
def _import():
from mautrix.types import (
ContentURI, EventID, EventType, PaginationDirection,
PresenceState, RoomCreatePreset, RoomID, SyncToken,
TrustState, UserID,
)
return {
"ContentURI": ContentURI,
"EventID": EventID,
"EventType": EventType,
"PaginationDirection": PaginationDirection,
"PresenceState": PresenceState,
"RoomCreatePreset": RoomCreatePreset,
"RoomID": RoomID,
"SyncToken": SyncToken,
"TrustState": TrustState,
"UserID": UserID,
}
from tools.lazy_deps import ensure_and_bind
if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False):
logger.warning(
"Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'"
)

View file

@ -76,27 +76,26 @@ def check_slack_requirements() -> bool:
"""Check if Slack dependencies are available.
Lazy-installs slack-bolt/slack-sdk via ``tools.lazy_deps.ensure("platform.slack")``
on first call if not present.
on first call if not present. Rebinds all module-level globals on success.
"""
global SLACK_AVAILABLE, AsyncApp, AsyncSocketModeHandler, AsyncWebClient
if SLACK_AVAILABLE:
return True
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("platform.slack", prompt=False)
except Exception:
return False
try:
from slack_bolt.async_app import AsyncApp as _App
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler as _Handler
from slack_sdk.web.async_client import AsyncWebClient as _Client
except ImportError:
return False
AsyncApp = _App
AsyncSocketModeHandler = _Handler
AsyncWebClient = _Client
SLACK_AVAILABLE = True
return True
def _import():
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from slack_sdk.web.async_client import AsyncWebClient
import aiohttp
return {
"AsyncApp": AsyncApp,
"AsyncSocketModeHandler": AsyncSocketModeHandler,
"AsyncWebClient": AsyncWebClient,
"aiohttp": aiohttp,
"SLACK_AVAILABLE": True,
}
from tools.lazy_deps import ensure_and_bind
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
def _extract_text_from_slack_blocks(blocks: list) -> str:

View file

@ -83,7 +83,7 @@ hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1"]
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
cli = ["simple-term-menu==1.6.6"]
tts-premium = ["elevenlabs==1.59.0"]

View file

@ -59,7 +59,7 @@ import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
@ -440,3 +440,56 @@ def feature_install_command(feature: str) -> Optional[str]:
return None
specs = LAZY_DEPS[feature]
return "uv pip install " + " ".join(repr(s) for s in specs)
def ensure_and_bind(
feature: str,
importer: Callable[[], dict[str, Any]],
target_globals: dict,
*,
prompt: bool = False,
) -> bool:
"""Ensure a feature is installed, then rebind names into the caller's globals.
Combines :func:`ensure` with a post-install import step that rebinds
module-level names. This eliminates the error-prone pattern of manually
listing every global that needs updating after lazy-install.
``importer`` is a zero-arg callable that returns a dict of
``{name: value}`` for all symbols the caller needs rebound. It is called
only after :func:`ensure` succeeds (or if the packages are already
installed).
Returns True on success, False if deps couldn't be installed or imported.
Example usage in a platform adapter::
def check_slack_requirements() -> bool:
if SLACK_AVAILABLE:
return True
def _import():
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from slack_sdk.web.async_client import AsyncWebClient
import aiohttp
return {
"AsyncApp": AsyncApp,
"AsyncSocketModeHandler": AsyncSocketModeHandler,
"AsyncWebClient": AsyncWebClient,
"aiohttp": aiohttp,
"SLACK_AVAILABLE": True,
}
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
"""
try:
ensure(feature, prompt=prompt)
except (FeatureUnavailable, Exception):
return False
try:
bindings = importer()
except ImportError:
return False
target_globals.update(bindings)
return True

2
uv.lock generated
View file

@ -2092,6 +2092,7 @@ rl = [
{ name = "wandb" },
]
slack = [
{ name = "aiohttp" },
{ name = "slack-bolt" },
{ name = "slack-sdk" },
]
@ -2149,6 +2150,7 @@ requires-dist = [
{ name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" },
{ name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.3" },
{ name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" },
{ name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" },
{ name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" },