diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index a82417a601..75d839e10a 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -374,9 +374,16 @@ class WhatsAppAdapter(BasePlatformAdapter): logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e) try: - # Auto-install npm dependencies if node_modules doesn't exist + # Auto-install npm dependencies if the bridge install is incomplete bridge_dir = bridge_path.parent - if not (bridge_dir / "node_modules").exists(): + node_modules_dir = bridge_dir / "node_modules" + dependency_sentinels = [ + node_modules_dir / "@whiskeysockets" / "baileys" / "package.json", + node_modules_dir / "express" / "package.json", + node_modules_dir / "qrcode-terminal" / "package.json", + node_modules_dir / "pino" / "package.json", + ] + if not node_modules_dir.exists() or any(not sentinel.exists() for sentinel in dependency_sentinels): print(f"[{self.name}] Installing WhatsApp bridge dependencies...") try: install_result = subprocess.run( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7de68d2cb4..6f711d45b4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1324,7 +1324,15 @@ def cmd_whatsapp(args): print(f"\n✗ Bridge script not found at {bridge_script}") return - if not (bridge_dir / "node_modules").exists(): + node_modules_dir = bridge_dir / "node_modules" + dependency_sentinels = [ + node_modules_dir / "@whiskeysockets" / "baileys" / "package.json", + node_modules_dir / "express" / "package.json", + node_modules_dir / "qrcode-terminal" / "package.json", + node_modules_dir / "pino" / "package.json", + ] + + if not node_modules_dir.exists() or any(not sentinel.exists() for sentinel in dependency_sentinels): print("\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)...") npm = shutil.which("npm") if not npm: diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 29f7eee3af..f061fbd85d 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -214,6 +214,43 @@ class TestFileHandleClosedOnError: class TestConnectCleanup: """Verify failure paths release the scoped session lock.""" + @pytest.mark.asyncio + async def test_releases_lock_when_baileys_package_is_missing(self): + adapter = _make_adapter() + + def _path_exists(path_obj): + path_str = str(path_obj).replace("\\", "/") + if path_str.endswith("node_modules"): + return True + if path_str.endswith("@whiskeysockets/baileys/package.json"): + return False + return True + + install_result = MagicMock(returncode=0, stderr="") + mock_proc = MagicMock() + mock_proc.poll.return_value = 1 + mock_proc.returncode = 1 + mock_fh = MagicMock() + + with ( + patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), + patch.object(Path, "exists", autospec=True, side_effect=_path_exists), + patch.object(Path, "mkdir", return_value=None), + patch("subprocess.run", return_value=install_result) as mock_run, + patch("subprocess.Popen", return_value=mock_proc), + patch("builtins.open", return_value=mock_fh), + patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), + patch("gateway.platforms.whatsapp.asyncio.create_task"), + patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), + patch("gateway.status.release_scoped_lock") as mock_release, + ): + result = await adapter.connect() + + assert result is False + assert any(call.args and call.args[0][:2] == ["npm", "install"] for call in mock_run.call_args_list) + mock_release.assert_called_once_with("whatsapp-session", str(adapter._session_path)) + assert adapter._platform_lock_identity is None + @pytest.mark.asyncio async def test_releases_lock_when_npm_install_fails(self): adapter = _make_adapter()