diff --git a/gateway/config.py b/gateway/config.py index 0ff3127ce1..470eee7f2f 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -779,6 +779,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.MATRIX].extra["password"] = matrix_password matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee + matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "") + if matrix_device_id: + config.platforms[Platform.MATRIX].extra["device_id"] = matrix_device_id matrix_home = os.getenv("MATRIX_HOME_ROOM") if matrix_home and Platform.MATRIX in config.platforms: config.platforms[Platform.MATRIX].home_channel = HomeChannel( diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 35cf72ad41..2dc0c5a9b6 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -10,6 +10,7 @@ Environment variables: MATRIX_USER_ID Full user ID (@bot:server) — required for password login MATRIX_PASSWORD Password (alternative to access token) MATRIX_ENCRYPTION Set "true" to enable E2EE + MATRIX_DEVICE_ID Stable device ID for E2EE persistence across restarts MATRIX_ALLOWED_USERS Comma-separated Matrix user IDs (@user:server) MATRIX_HOME_ROOM Room ID for cron/notification delivery MATRIX_REACTIONS Set "false" to disable processing lifecycle reactions @@ -65,6 +66,21 @@ _MAX_PENDING_EVENTS = 100 _PENDING_EVENT_TTL = 300 # seconds — stop retrying after 5 min +_E2EE_INSTALL_HINT = ( + "Install with: pip install 'matrix-nio[e2e]' " + "(requires libolm C library)" +) + + +def _check_e2ee_deps() -> bool: + """Return True if matrix-nio E2EE dependencies (python-olm) are available.""" + try: + from nio.crypto import ENCRYPTION_ENABLED + return bool(ENCRYPTION_ENABLED) + except (ImportError, AttributeError): + return False + + def check_matrix_requirements() -> bool: """Return True if the Matrix adapter can be used.""" token = os.getenv("MATRIX_ACCESS_TOKEN", "") @@ -79,7 +95,6 @@ def check_matrix_requirements() -> bool: return False try: import nio # noqa: F401 - return True except ImportError: logger.warning( "Matrix: matrix-nio not installed. " @@ -87,6 +102,20 @@ def check_matrix_requirements() -> bool: ) return False + # If encryption is requested, verify E2EE deps are available at startup + # rather than silently degrading to plaintext-only at connect time. + encryption_requested = os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes") + if encryption_requested and not _check_e2ee_deps(): + logger.error( + "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " + "Without this, encrypted rooms will not work. " + "Set MATRIX_ENCRYPTION=false to disable E2EE.", + _E2EE_INSTALL_HINT, + ) + return False + + return True + class MatrixAdapter(BasePlatformAdapter): """Gateway adapter for Matrix (any homeserver).""" @@ -111,6 +140,10 @@ class MatrixAdapter(BasePlatformAdapter): "encryption", os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes"), ) + self._device_id: str = ( + config.extra.get("device_id", "") + or os.getenv("MATRIX_DEVICE_ID", "") + ) self._client: Any = None # nio.AsyncClient self._sync_task: Optional[asyncio.Task] = None @@ -169,24 +202,42 @@ class MatrixAdapter(BasePlatformAdapter): _STORE_DIR.mkdir(parents=True, exist_ok=True) # Create the client. + # When a stable device_id is configured, pass it to the constructor + # so matrix-nio binds to it from the start (important for E2EE + # crypto-store persistence across restarts). + ctor_device_id = self._device_id or None if self._encryption: + if not _check_e2ee_deps(): + logger.error( + "Matrix: MATRIX_ENCRYPTION=true but E2EE dependencies are missing. %s. " + "Refusing to connect — encrypted rooms would silently fail.", + _E2EE_INSTALL_HINT, + ) + return False try: client = nio.AsyncClient( self._homeserver, self._user_id or "", + device_id=ctor_device_id, store_path=store_path, ) - logger.info("Matrix: E2EE enabled (store: %s)", store_path) - except Exception as exc: - logger.warning( - "Matrix: failed to create E2EE client (%s), " - "falling back to plain client. Install: " - "pip install 'matrix-nio[e2e]'", - exc, + logger.info( + "Matrix: E2EE enabled (store: %s%s)", + store_path, + f", device_id={self._device_id}" if self._device_id else "", ) - client = nio.AsyncClient(self._homeserver, self._user_id or "") + except Exception as exc: + logger.error( + "Matrix: failed to create E2EE client: %s. %s", + exc, _E2EE_INSTALL_HINT, + ) + return False else: - client = nio.AsyncClient(self._homeserver, self._user_id or "") + client = nio.AsyncClient( + self._homeserver, + self._user_id or "", + device_id=ctor_device_id, + ) self._client = client @@ -205,30 +256,36 @@ class MatrixAdapter(BasePlatformAdapter): if resolved_user_id: self._user_id = resolved_user_id + # Prefer the user-configured device_id (MATRIX_DEVICE_ID) so + # the bot reuses a stable identity across restarts. Fall back + # to whatever whoami returned. + effective_device_id = self._device_id or resolved_device_id + # restore_login() is the matrix-nio path that binds the access # token to a specific device and loads the crypto store. - if resolved_device_id and hasattr(client, "restore_login"): + if effective_device_id and hasattr(client, "restore_login"): client.restore_login( self._user_id or resolved_user_id, - resolved_device_id, + effective_device_id, self._access_token, ) else: if self._user_id: client.user_id = self._user_id - if resolved_device_id: - client.device_id = resolved_device_id + if effective_device_id: + client.device_id = effective_device_id client.access_token = self._access_token if self._encryption: logger.warning( "Matrix: access-token login did not restore E2EE state; " - "encrypted rooms may fail until a device_id is available" + "encrypted rooms may fail until a device_id is available. " + "Set MATRIX_DEVICE_ID to a stable value." ) logger.info( "Matrix: using access token for %s%s", self._user_id or "(unknown user)", - f" (device {resolved_device_id})" if resolved_device_id else "", + f" (device {effective_device_id})" if effective_device_id else "", ) else: logger.error( @@ -271,10 +328,15 @@ class MatrixAdapter(BasePlatformAdapter): except Exception as exc: logger.debug("Matrix: could not import keys: %s", exc) elif self._encryption: - logger.warning( - "Matrix: E2EE requested but crypto store is not loaded; " - "encrypted rooms may fail" + # E2EE was requested but the crypto store failed to load — + # this means encrypted rooms will silently not work. Hard-fail. + logger.error( + "Matrix: E2EE requested but crypto store is not loaded — " + "cannot decrypt or encrypt messages. %s", + _E2EE_INSTALL_HINT, ) + await client.close() + return False # Register event callbacks. client.add_event_callback(self._on_room_message, nio.RoomMessageText) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 369fe7acf0..bf0b27c205 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -42,7 +42,7 @@ _EXTRA_ENV_KEYS = frozenset({ "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", - "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM", + "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", }) import yaml @@ -1079,6 +1079,14 @@ OPTIONAL_ENV_VARS = { "category": "messaging", "advanced": True, }, + "MATRIX_DEVICE_ID": { + "description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)", + "prompt": "Matrix device ID (stable across restarts)", + "url": None, + "password": False, + "category": "messaging", + "advanced": True, + }, "GATEWAY_ALLOW_ALL_USERS": { "description": "Allow all users to interact with messaging bots (true/false). Default: false.", "prompt": "Allow all users (true/false)", diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index fb2d47f497..09f0ab9590 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -428,6 +428,7 @@ class TestMatrixRequirements: def test_check_requirements_with_token(self, monkeypatch): monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) from gateway.platforms.matrix import check_matrix_requirements try: import nio # noqa: F401 @@ -448,6 +449,45 @@ class TestMatrixRequirements: from gateway.platforms.matrix import check_matrix_requirements assert check_matrix_requirements() is False + def test_check_requirements_encryption_true_no_e2ee_deps(self, monkeypatch): + """MATRIX_ENCRYPTION=true should fail if python-olm is not installed.""" + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_ENCRYPTION", "true") + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + assert matrix_mod.check_matrix_requirements() is False + + def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): + """Without encryption, missing E2EE deps should not block startup.""" + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + # Still needs nio itself to be importable + try: + import nio # noqa: F401 + assert matrix_mod.check_matrix_requirements() is True + except ImportError: + assert matrix_mod.check_matrix_requirements() is False + + def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch): + """MATRIX_ENCRYPTION=true should pass if E2EE deps are available.""" + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_ENCRYPTION", "true") + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + try: + import nio # noqa: F401 + assert matrix_mod.check_matrix_requirements() is True + except ImportError: + assert matrix_mod.check_matrix_requirements() is False + # --------------------------------------------------------------------------- # Access-token auth / E2EE bootstrap @@ -516,10 +556,12 @@ class TestMatrixAccessTokenAuth: fake_nio.InviteMemberEvent = type("InviteMemberEvent", (), {}) fake_nio.MegolmEvent = type("MegolmEvent", (), {}) - with patch.dict("sys.modules", {"nio": fake_nio}): - with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): - with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): - assert await adapter.connect() is True + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True fake_client.restore_login.assert_called_once_with( "@bot:example.org", "DEV123", "syt_test_access_token" @@ -532,6 +574,326 @@ class TestMatrixAccessTokenAuth: await adapter.disconnect() +class TestMatrixE2EEHardFail: + """connect() must refuse to start when E2EE is requested but deps are missing.""" + + @pytest.mark.asyncio + async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + }, + ) + adapter = MatrixAdapter(config) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock() + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): + with patch.dict("sys.modules", {"nio": fake_nio}): + result = await adapter.connect() + + assert result is False + + @pytest.mark.asyncio + async def test_connect_fails_when_olm_not_loaded_after_login(self): + """Even if _check_e2ee_deps passes, if olm is None after auth, hard-fail.""" + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + }, + ) + adapter = MatrixAdapter(config) + + class FakeWhoamiResponse: + def __init__(self, user_id, device_id): + self.user_id = user_id + self.device_id = device_id + + fake_client = MagicMock() + fake_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "DEV123")) + fake_client.close = AsyncMock() + # olm is None — crypto store not loaded + fake_client.olm = None + fake_client.should_upload_keys = False + + def _restore_login(user_id, device_id, access_token): + fake_client.user_id = user_id + fake_client.device_id = device_id + fake_client.access_token = access_token + + fake_client.restore_login = MagicMock(side_effect=_restore_login) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(return_value=fake_client) + fake_nio.WhoamiResponse = FakeWhoamiResponse + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + result = await adapter.connect() + + assert result is False + fake_client.close.assert_awaited_once() + + +class TestMatrixDeviceId: + """MATRIX_DEVICE_ID should be used for stable device identity.""" + + def test_device_id_from_config_extra(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test", + extra={ + "homeserver": "https://matrix.example.org", + "device_id": "HERMES_BOT_STABLE", + }, + ) + adapter = MatrixAdapter(config) + assert adapter._device_id == "HERMES_BOT_STABLE" + + def test_device_id_from_env(self, monkeypatch): + monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV") + + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test", + extra={ + "homeserver": "https://matrix.example.org", + }, + ) + adapter = MatrixAdapter(config) + assert adapter._device_id == "FROM_ENV" + + def test_device_id_config_takes_precedence_over_env(self, monkeypatch): + monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV") + + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test", + extra={ + "homeserver": "https://matrix.example.org", + "device_id": "FROM_CONFIG", + }, + ) + adapter = MatrixAdapter(config) + assert adapter._device_id == "FROM_CONFIG" + + @pytest.mark.asyncio + async def test_connect_uses_configured_device_id_over_whoami(self): + """When MATRIX_DEVICE_ID is set, it should be used instead of whoami device_id.""" + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + "device_id": "MY_STABLE_DEVICE", + }, + ) + adapter = MatrixAdapter(config) + + class FakeWhoamiResponse: + def __init__(self, user_id, device_id): + self.user_id = user_id + self.device_id = device_id + + class FakeSyncResponse: + def __init__(self): + self.rooms = MagicMock(join={}) + + fake_client = MagicMock() + fake_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "WHOAMI_DEV")) + fake_client.sync = AsyncMock(return_value=FakeSyncResponse()) + fake_client.keys_upload = AsyncMock() + fake_client.keys_query = AsyncMock() + fake_client.keys_claim = AsyncMock() + fake_client.send_to_device_messages = AsyncMock(return_value=[]) + fake_client.get_users_for_key_claiming = MagicMock(return_value={}) + fake_client.close = AsyncMock() + fake_client.add_event_callback = MagicMock() + fake_client.rooms = {} + fake_client.account_data = {} + fake_client.olm = object() + fake_client.should_upload_keys = False + fake_client.should_query_keys = False + fake_client.should_claim_keys = False + + def _restore_login(user_id, device_id, access_token): + fake_client.user_id = user_id + fake_client.device_id = device_id + fake_client.access_token = access_token + + fake_client.restore_login = MagicMock(side_effect=_restore_login) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(return_value=fake_client) + fake_nio.WhoamiResponse = FakeWhoamiResponse + fake_nio.SyncResponse = FakeSyncResponse + fake_nio.LoginResponse = type("LoginResponse", (), {}) + fake_nio.RoomMessageText = type("RoomMessageText", (), {}) + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.InviteMemberEvent = type("InviteMemberEvent", (), {}) + fake_nio.MegolmEvent = type("MegolmEvent", (), {}) + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True + + # The configured device_id should override the whoami device_id + fake_client.restore_login.assert_called_once_with( + "@bot:example.org", "MY_STABLE_DEVICE", "syt_test_access_token" + ) + assert fake_client.device_id == "MY_STABLE_DEVICE" + + # Verify device_id was passed to nio.AsyncClient constructor + ctor_call = fake_nio.AsyncClient.call_args + assert ctor_call.kwargs.get("device_id") == "MY_STABLE_DEVICE" + + await adapter.disconnect() + + +class TestMatrixE2EEClientConstructorFailure: + """connect() should hard-fail if nio.AsyncClient() raises when encryption is on.""" + + @pytest.mark.asyncio + async def test_connect_fails_when_e2ee_client_constructor_raises(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_access_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "encryption": True, + }, + ) + adapter = MatrixAdapter(config) + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(side_effect=Exception("olm init failed")) + + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + result = await adapter.connect() + + assert result is False + + +class TestMatrixPasswordLoginDeviceId: + """MATRIX_DEVICE_ID should be passed to nio.AsyncClient even with password login.""" + + @pytest.mark.asyncio + async def test_password_login_passes_device_id_to_constructor(self): + from gateway.platforms.matrix import MatrixAdapter + + config = PlatformConfig( + enabled=True, + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + "password": "secret", + "device_id": "STABLE_PW_DEVICE", + }, + ) + adapter = MatrixAdapter(config) + + class FakeLoginResponse: + pass + + class FakeSyncResponse: + def __init__(self): + self.rooms = MagicMock(join={}) + + fake_client = MagicMock() + fake_client.login = AsyncMock(return_value=FakeLoginResponse()) + fake_client.sync = AsyncMock(return_value=FakeSyncResponse()) + fake_client.close = AsyncMock() + fake_client.add_event_callback = MagicMock() + fake_client.rooms = {} + fake_client.account_data = {} + + fake_nio = MagicMock() + fake_nio.AsyncClient = MagicMock(return_value=fake_client) + fake_nio.LoginResponse = FakeLoginResponse + fake_nio.SyncResponse = FakeSyncResponse + fake_nio.RoomMessageText = type("RoomMessageText", (), {}) + fake_nio.RoomMessageImage = type("RoomMessageImage", (), {}) + fake_nio.RoomMessageAudio = type("RoomMessageAudio", (), {}) + fake_nio.RoomMessageVideo = type("RoomMessageVideo", (), {}) + fake_nio.RoomMessageFile = type("RoomMessageFile", (), {}) + fake_nio.InviteMemberEvent = type("InviteMemberEvent", (), {}) + + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True + + # Verify device_id was passed to the nio.AsyncClient constructor + ctor_call = fake_nio.AsyncClient.call_args + assert ctor_call.kwargs.get("device_id") == "STABLE_PW_DEVICE" + + await adapter.disconnect() + + +class TestMatrixDeviceIdConfig: + """MATRIX_DEVICE_ID should be plumbed through gateway config.""" + + def test_device_id_in_config_extra(self, monkeypatch): + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.setenv("MATRIX_DEVICE_ID", "HERMES_BOT") + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + mc = config.platforms[Platform.MATRIX] + assert mc.extra.get("device_id") == "HERMES_BOT" + + def test_device_id_not_set_when_env_empty(self, monkeypatch): + monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123") + monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") + monkeypatch.delenv("MATRIX_DEVICE_ID", raising=False) + + from gateway.config import GatewayConfig, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + + mc = config.platforms[Platform.MATRIX] + assert "device_id" not in mc.extra + + class TestMatrixE2EEMaintenance: @pytest.mark.asyncio async def test_sync_loop_runs_e2ee_maintenance_requests(self): @@ -1071,10 +1433,12 @@ class TestMatrixEncryptedMedia: fake_nio.InviteMemberEvent = FakeInviteMemberEvent fake_nio.MegolmEvent = FakeMegolmEvent - with patch.dict("sys.modules", {"nio": fake_nio}): - with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): - with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): - assert await adapter.connect() is True + from gateway.platforms import matrix as matrix_mod + with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True): + with patch.dict("sys.modules", {"nio": fake_nio}): + with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + assert await adapter.connect() is True callback_classes = [call.args[1] for call in fake_client.add_event_callback.call_args_list] assert FakeRoomEncryptedImage in callback_classes