diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index e282067e46..d59db86a20 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -174,34 +174,54 @@ def dingtalk_deps_present() -> bool: return DINGTALK_STREAM_AVAILABLE and HTTPX_AVAILABLE -def check_dingtalk_requirements() -> bool: - """Check if DingTalk dependencies are available and configured. +def ensure_dingtalk_deps() -> bool: + """ACTIVE deps-only installer (registry ``ensure_deps_fn``). - Lazy-installs dingtalk-stream via ``tools.lazy_deps.ensure("platform.dingtalk")`` - on first call if not present. + Lazy-installs dingtalk-stream/httpx and rebinds module globals. + Deliberately does NOT check credentials — ``ensure_deps_fn``'s contract + is deps-only ("Returns True once deps are importable"); credentials are + gated by ``is_connected``/``validate_config``. Otherwise a platform + configured via ``PlatformConfig.extra`` (which ``_is_connected`` + accepts) would pass enablement, reach ``create_adapter()``, and have + the installer veto on env-var grounds before ever installing — + re-creating the #79812 deadlock for extra-configured setups. """ global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage global HTTPX_AVAILABLE, httpx - if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE: - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.dingtalk", prompt=False) - except Exception: - return False - try: - import dingtalk_stream as _ds - from dingtalk_stream import ChatbotMessage as _CM - from dingtalk_stream.frames import CallbackMessage as _CBM, AckMessage as _AM - import httpx as _httpx - except Exception: - return False - dingtalk_stream = _ds - ChatbotMessage = _CM - CallbackMessage = _CBM - AckMessage = _AM - httpx = _httpx - DINGTALK_STREAM_AVAILABLE = True - HTTPX_AVAILABLE = True + if DINGTALK_STREAM_AVAILABLE and HTTPX_AVAILABLE: + return True + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("platform.dingtalk", prompt=False) + except Exception: + return False + try: + import dingtalk_stream as _ds + from dingtalk_stream import ChatbotMessage as _CM + from dingtalk_stream.frames import CallbackMessage as _CBM, AckMessage as _AM + import httpx as _httpx + except Exception: + return False + dingtalk_stream = _ds + ChatbotMessage = _CM + CallbackMessage = _CBM + AckMessage = _AM + httpx = _httpx + DINGTALK_STREAM_AVAILABLE = True + HTTPX_AVAILABLE = True + return True + + +def check_dingtalk_requirements() -> bool: + """Check if DingTalk dependencies are available and configured. + + Lazy-installs dingtalk-stream via :func:`ensure_dingtalk_deps`, then + additionally requires credentials. Kept for setup/status callers that + want the combined deps+credentials answer; the registry uses the + deps-only :func:`ensure_dingtalk_deps` as ``ensure_deps_fn``. + """ + if not ensure_dingtalk_deps(): + return False if not os.getenv("DINGTALK_CLIENT_ID") or not _get_scoped_secret("DINGTALK_CLIENT_SECRET"): return False return True @@ -1894,7 +1914,7 @@ def register(ctx) -> None: label="DingTalk", adapter_factory=_build_adapter, check_fn=dingtalk_deps_present, - ensure_deps_fn=check_dingtalk_requirements, + ensure_deps_fn=ensure_dingtalk_deps, is_connected=_is_connected, validate_config=_is_connected, required_env=["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"], diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 6ea37ed818..5b2e4ec4bd 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -1451,7 +1451,7 @@ def feishu_deps_present() -> bool: """PASSIVE probe: is lark-oapi installed right now? Registry ``check_fn`` — called from status displays and config loading, - so it must never install anything. Uses ``feature_missing`` (cheap + so it must never install anything. Uses ``is_available`` (cheap importlib.metadata lookups) instead of importing the SDK, which is deferred to ``_load_lark_oapi`` at connect time. The ACTIVE lazy-installer (``check_feishu_requirements``) is registered as @@ -1461,8 +1461,8 @@ def feishu_deps_present() -> bool: if FEISHU_AVAILABLE: return True try: - from tools.lazy_deps import feature_missing - return not feature_missing("platform.feishu") + from tools.lazy_deps import is_available + return is_available("platform.feishu") except Exception: # pragma: no cover — defensive return False diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 9745ffba59..1ee95dc9c5 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -892,14 +892,13 @@ def matrix_deps_present() -> bool: """PASSIVE probe: are the ``platform.matrix`` packages installed? Registry ``check_fn`` — called from status displays and config loading, - so it must never install anything. ``feature_missing`` is cheap - (per-spec importlib.metadata lookups). The ACTIVE lazy-installer + so it must never install anything. The ACTIVE lazy-installer (``check_matrix_requirements``) is registered as ``ensure_deps_fn`` and runs from ``create_adapter()`` when this returns False (#79812). """ try: - from tools.lazy_deps import feature_missing - return not feature_missing("platform.matrix") + from tools.lazy_deps import is_available + return is_available("platform.matrix") except Exception: # pragma: no cover — defensive return False @@ -907,13 +906,12 @@ def matrix_deps_present() -> bool: def check_matrix_requirements() -> bool: """Return True if the Matrix adapter can be used. - Lazy-installs the full ``platform.matrix`` feature group via - ``tools.lazy_deps.ensure_and_bind`` whenever any of the declared - packages (mautrix, Markdown, aiosqlite, asyncpg, aiohttp-socks) is - missing — not just mautrix itself. Previously this short-circuited on - ``import mautrix``, which left the other four packages uninstalled - forever and broke E2EE connect with ``No module named 'asyncpg'`` - (#31116). Rebinds module-level type globals on success. + Combined credentials + deps answer for setup/status callers. The + registry's ``ensure_deps_fn`` is the deps-only + :func:`ensure_matrix_deps` below — credentials must NOT gate the + installer (they're handled by ``is_connected``, which also accepts + ``PlatformConfig.extra``-configured setups that these env checks + would wrongly veto). """ token = _startup_env_secret("MATRIX_ACCESS_TOKEN") password = _startup_env_secret("MATRIX_PASSWORD") @@ -926,6 +924,20 @@ def check_matrix_requirements() -> bool: logger.warning("Matrix: MATRIX_HOMESERVER not set") return False + return ensure_matrix_deps() + + +def ensure_matrix_deps() -> bool: + """ACTIVE deps-only installer (registry ``ensure_deps_fn``). + + Lazy-installs the full ``platform.matrix`` feature group via + ``tools.lazy_deps.ensure_and_bind`` whenever any of the declared + packages (mautrix, Markdown, aiosqlite, asyncpg, aiohttp-socks) is + missing — not just mautrix itself. Previously this short-circuited on + ``import mautrix``, which left the other four packages uninstalled + forever and broke E2EE connect with ``No module named 'asyncpg'`` + (#31116). Rebinds module-level type globals on success. + """ # Check whether any package in the platform.matrix feature group is # missing. ``feature_missing`` is cheap (per-spec importlib.metadata # lookups) and correctly handles ``mautrix[encryption]`` by stripping @@ -5293,7 +5305,7 @@ def register(ctx) -> None: label="Matrix", adapter_factory=_build_adapter, check_fn=matrix_deps_present, - ensure_deps_fn=check_matrix_requirements, + ensure_deps_fn=ensure_matrix_deps, is_connected=_is_connected, required_env=["MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN"], install_hint="pip install 'mautrix[encryption]'", diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index 9acb857aa8..3cb96ffc72 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -6,7 +6,8 @@ Runs an aiohttp webhook server to receive messages from Teams. Proactive messaging (send, typing) uses the SDK's App.send() method. Requires: - pip install microsoft-teams-apps aiohttp + the ``teams`` extra (auto-installed by the gateway on first start, or + manually: ``/bin/pip install microsoft-teams-apps aiohttp``) TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID env vars Configuration in config.yaml: diff --git a/plugins/platforms/wecom/callback_adapter.py b/plugins/platforms/wecom/callback_adapter.py index c4e1a19ec5..dc2d682960 100644 --- a/plugins/platforms/wecom/callback_adapter.py +++ b/plugins/platforms/wecom/callback_adapter.py @@ -88,7 +88,6 @@ def ensure_wecom_callback_requirements() -> bool: False forever on installs without the ``wecom`` extra and the ``platform.wecom_callback`` LAZY_DEPS entry was never exercised. """ - global ET, DEFUSEDXML_AVAILABLE if check_wecom_callback_requirements(): return True diff --git a/tests/gateway/test_platform_registry.py b/tests/gateway/test_platform_registry.py index 1181718d93..8a0442cb02 100644 --- a/tests/gateway/test_platform_registry.py +++ b/tests/gateway/test_platform_registry.py @@ -656,3 +656,44 @@ class TestPluginEnablementGate: assert cfg.platforms[plat].enabled is False finally: _reg.unregister("myhardblockplat") + + +class TestMigratedPlatformWiring: + """Every lazy-installable bundled platform must register the split: + a PASSIVE check_fn plus an ACTIVE ensure_deps_fn (#79812). + + Behavior contract, not a snapshot: asserts the two fields are distinct + callables (probe != installer), not specific function identities, so + renames don't churn this test. + """ + + import pytest as _pytest + + @_pytest.mark.parametrize( + "platform_name", + [ + "teams", "telegram", "discord", "slack", + "matrix", "dingtalk", "feishu", "wecom_callback", + ], + ) + def test_lazy_installable_platform_has_split_wiring(self, platform_name): + from hermes_cli.plugins import discover_plugins + + discover_plugins() + from gateway.platform_registry import platform_registry + + # Materialize deferred loaders (wecom_callback is registered by the + # "wecom" manifest's loader; a cold get() by its own name misses). + platform_registry.plugin_entries() + entry = platform_registry.get(platform_name) + assert entry is not None, f"{platform_name} not registered" + assert entry.ensure_deps_fn is not None, ( + f"{platform_name} has a lazy-installable SDK but no " + "ensure_deps_fn — its deps can never auto-install " + "(the #79812 deadlock)" + ) + assert entry.ensure_deps_fn is not entry.check_fn, ( + f"{platform_name} registered the same callable for the passive " + "probe and the active installer — status displays would " + "pip-install as a side effect" + ) diff --git a/website/docs/user-guide/messaging/teams.md b/website/docs/user-guide/messaging/teams.md index 11c132bc64..626958355f 100644 --- a/website/docs/user-guide/messaging/teams.md +++ b/website/docs/user-guide/messaging/teams.md @@ -255,7 +255,7 @@ Make sure your configured port (`TEAMS_PORT`, default `3978`) is reachable from | Problem | Solution | |---------|----------| | `Can't find a suitable configuration file` from `docker compose` | You are not in the repo that has `docker-compose.yml`, or you are on a native install — use `hermes gateway restart` instead, or `cd` into the clone first | -| `requirements not met (pip install microsoft-teams-apps …)` / `No adapter available for teams` | Restart gateway so lazy-install can run, or install into the **Hermes venv**: `~/.hermes/hermes-agent/venv/bin/pip install microsoft-teams-apps aiohttp`. System `pip` fails on Ubuntu 24.04 (PEP 668) and would not affect the service anyway | +| `requirements not met` / `Teams SDK missing` / `No adapter available for teams` | Restart gateway so lazy-install can run, or install into the **Hermes venv**: `~/.hermes/hermes-agent/venv/bin/pip install microsoft-teams-apps aiohttp`. System `pip` fails on Ubuntu 24.04 (PEP 668) and would not affect the service anyway | | `health` endpoint works but bot doesn't respond | Check that your tunnel is still running and the bot's messaging endpoint matches the tunnel URL | | `KeyError: 'teams'` in logs | Restart the container — this is fixed in the current version | | Bot responds with auth errors | Verify `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, and `TEAMS_TENANT_ID` are all set correctly |