From 6454f0f83c2f5a877c07754600634438d74baa39 Mon Sep 17 00:00:00 2001 From: MoltBot Service Date: Fri, 13 Feb 2026 22:41:56 +0000 Subject: [PATCH] install Echo Core as systemd service, update CLI for systemctl - Created echo-core.service and echo-whatsapp-bridge.service (user units) - CLI status/doctor now use systemctl --user show instead of PID file - CLI restart uses kill+start pattern for reliability - Added echo stop command - CLI shebang uses venv python directly for keyring support - Updated tests to mock _get_service_status instead of PID file - 440 tests pass Co-Authored-By: Claude Opus 4.6 --- cli.py | 170 +++++++++++++++++++++++++++++++--------------- tests/test_cli.py | 90 ++++++++++++------------ 2 files changed, 157 insertions(+), 103 deletions(-) diff --git a/cli.py b/cli.py index a5c5b19..58b85d4 100755 --- a/cli.py +++ b/cli.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/home/moltbot/echo-core/.venv/bin/python3 """Echo Core CLI tool.""" import argparse @@ -27,38 +27,72 @@ CONFIG_FILE = PROJECT_ROOT / "config.json" # Subcommand handlers # --------------------------------------------------------------------------- +SERVICE_NAME = "echo-core.service" +BRIDGE_SERVICE_NAME = "echo-whatsapp-bridge.service" + + +def _systemctl(*cmd_args) -> tuple[int, str]: + """Run systemctl --user and return (returncode, stdout).""" + import subprocess + result = subprocess.run( + ["systemctl", "--user", *cmd_args], + capture_output=True, text=True, timeout=30, + ) + return result.returncode, result.stdout.strip() + + +def _get_service_status(service: str) -> dict: + """Get service ActiveState, SubState, MainPID, and ActiveEnterTimestamp.""" + import subprocess + result = subprocess.run( + ["systemctl", "--user", "show", service, + "--property=ActiveState,SubState,MainPID,ActiveEnterTimestamp"], + capture_output=True, text=True, timeout=30, + ) + info = {} + for line in result.stdout.strip().splitlines(): + if "=" in line: + k, v = line.split("=", 1) + info[k] = v + return info + + def cmd_status(args): """Show bot status: online/offline, uptime, active sessions.""" - # Check PID file - if not PID_FILE.exists(): - print("Status: OFFLINE (no PID file)") - _print_session_count() - return + # Echo Core service + info = _get_service_status(SERVICE_NAME) + active = info.get("ActiveState", "unknown") + pid = info.get("MainPID", "0") + ts = info.get("ActiveEnterTimestamp", "") - try: - pid = int(PID_FILE.read_text().strip()) - except (ValueError, OSError): - print("Status: OFFLINE (invalid PID file)") - _print_session_count() - return + if active == "active": + # Parse uptime from ActiveEnterTimestamp + uptime_str = "" + if ts: + try: + started = datetime.strptime(ts.strip(), "%a %Y-%m-%d %H:%M:%S %Z") + started = started.replace(tzinfo=timezone.utc) + uptime = datetime.now(timezone.utc) - started + hours, remainder = divmod(int(uptime.total_seconds()), 3600) + minutes, seconds = divmod(remainder, 60) + uptime_str = f"{hours}h {minutes}m {seconds}s" + except (ValueError, OSError): + uptime_str = "?" + print(f"Echo Core: ONLINE (PID {pid})") + if uptime_str: + print(f"Uptime: {uptime_str}") + else: + print(f"Echo Core: OFFLINE ({active})") - # Check if process is alive - try: - os.kill(pid, 0) - except OSError: - print(f"Status: OFFLINE (PID {pid} not running)") - _print_session_count() - return + # WhatsApp bridge service + bridge_info = _get_service_status(BRIDGE_SERVICE_NAME) + bridge_active = bridge_info.get("ActiveState", "unknown") + bridge_pid = bridge_info.get("MainPID", "0") + if bridge_active == "active": + print(f"WA Bridge: ONLINE (PID {bridge_pid})") + else: + print(f"WA Bridge: OFFLINE ({bridge_active})") - # Process alive — calculate uptime from PID file mtime - mtime = PID_FILE.stat().st_mtime - started = datetime.fromtimestamp(mtime, tz=timezone.utc) - uptime = datetime.now(timezone.utc) - started - hours, remainder = divmod(int(uptime.total_seconds()), 3600) - minutes, seconds = divmod(remainder, 60) - - print(f"Status: ONLINE (PID {pid})") - print(f"Uptime: {hours}h {minutes}m {seconds}s") _print_session_count() @@ -181,16 +215,17 @@ def cmd_doctor(args): else: checks.append(("Telegram token (optional)", True)) # not required - # 11. Discord connection (bot PID running) - pid_ok = False - if PID_FILE.exists(): - try: - pid = int(PID_FILE.read_text().strip()) - os.kill(pid, 0) - pid_ok = True - except (ValueError, OSError): - pass - checks.append(("Bot process running", pid_ok)) + # 11. Echo Core service running + info = _get_service_status(SERVICE_NAME) + checks.append(("Echo Core service running", info.get("ActiveState") == "active")) + + # 12. WhatsApp bridge service running (optional) + bridge_info = _get_service_status(BRIDGE_SERVICE_NAME) + bridge_active = bridge_info.get("ActiveState") == "active" + if bridge_active: + checks.append(("WhatsApp bridge running", True)) + else: + checks.append(("WhatsApp bridge (optional)", True)) # Print results all_pass = True @@ -209,26 +244,43 @@ def cmd_doctor(args): def cmd_restart(args): - """Restart the bot by sending SIGTERM to the running process.""" - if not PID_FILE.exists(): - print("Error: no PID file found (bot not running?)") + """Restart the bot via systemctl (kill + start).""" + import time + + # Also restart bridge if requested + if getattr(args, "bridge", False): + print("Restarting WhatsApp bridge...") + _systemctl("kill", BRIDGE_SERVICE_NAME) + time.sleep(2) + _systemctl("start", BRIDGE_SERVICE_NAME) + + print("Restarting Echo Core...") + _systemctl("kill", SERVICE_NAME) + time.sleep(2) + _systemctl("start", SERVICE_NAME) + time.sleep(3) + + info = _get_service_status(SERVICE_NAME) + if info.get("ActiveState") == "active": + print(f"Echo Core restarted (PID {info.get('MainPID')}).") + elif info.get("ActiveState") == "activating": + print("Echo Core starting...") + else: + print(f"Warning: Echo Core status is {info.get('ActiveState')}") sys.exit(1) - try: - pid = int(PID_FILE.read_text().strip()) - except (ValueError, OSError): - print("Error: invalid PID file") - sys.exit(1) - # Check process alive - try: - os.kill(pid, 0) - except OSError: - print(f"Error: process {pid} is not running") - sys.exit(1) - - os.kill(pid, signal.SIGTERM) - print(f"Sent SIGTERM to PID {pid}") +def cmd_stop(args): + """Stop the bot via systemctl.""" + print("Stopping Echo Core...") + _systemctl("stop", "--no-block", SERVICE_NAME) + import time + time.sleep(2) + info = _get_service_status(SERVICE_NAME) + if info.get("ActiveState") in ("inactive", "deactivating"): + print("Echo Core stopped.") + else: + print(f"Echo Core status: {info.get('ActiveState')}") def cmd_logs(args): @@ -659,7 +711,12 @@ def main(): sub.add_parser("doctor", help="Run diagnostic checks") # restart - sub.add_parser("restart", help="Restart the bot (send SIGTERM)") + restart_parser = sub.add_parser("restart", help="Restart the bot via systemctl") + restart_parser.add_argument("--bridge", action="store_true", + help="Also restart WhatsApp bridge") + + # stop + sub.add_parser("stop", help="Stop the bot via systemctl") # logs logs_parser = sub.add_parser("logs", help="Show recent log lines") @@ -762,6 +819,7 @@ def main(): "status": cmd_status, "doctor": cmd_doctor, "restart": cmd_restart, + "stop": cmd_stop, "logs": cmd_logs, "sessions": lambda a: ( cmd_sessions(a) if a.sessions_action else (sessions_parser.print_help() or sys.exit(0)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3cb3669..e19258c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,7 @@ import argparse import json -import signal +import time from contextlib import ExitStack from unittest.mock import patch, MagicMock @@ -53,31 +53,18 @@ def iso(tmp_path, monkeypatch): class TestStatus: - def test_offline_no_pid(self, iso, capsys): - cli.cmd_status(_args()) - out = capsys.readouterr().out - assert "OFFLINE" in out - assert "no PID file" in out + def _mock_service(self, active="active", pid="1234", ts="Fri 2026-02-13 22:00:00 UTC"): + return {"ActiveState": active, "MainPID": pid, "ActiveEnterTimestamp": ts} - def test_offline_invalid_pid(self, iso, capsys): - iso["pid_file"].write_text("garbage") - cli.cmd_status(_args()) - out = capsys.readouterr().out - assert "OFFLINE" in out - assert "invalid PID file" in out - - def test_offline_stale_pid(self, iso, capsys): - iso["pid_file"].write_text("999999") - with patch("os.kill", side_effect=OSError): + def test_offline(self, iso, capsys): + with patch("cli._get_service_status", return_value=self._mock_service(active="inactive", pid="0")): cli.cmd_status(_args()) out = capsys.readouterr().out assert "OFFLINE" in out - assert "not running" in out def test_online(self, iso, capsys): - iso["pid_file"].write_text("1234") iso["sessions_file"].write_text(json.dumps({"ch1": {}})) - with patch("os.kill"): + with patch("cli._get_service_status", return_value=self._mock_service()): cli.cmd_status(_args()) out = capsys.readouterr().out assert "ONLINE" in out @@ -85,10 +72,20 @@ class TestStatus: assert "1 active" in out def test_sessions_count_zero(self, iso, capsys): - cli.cmd_status(_args()) + with patch("cli._get_service_status", return_value=self._mock_service(active="inactive")): + cli.cmd_status(_args()) out = capsys.readouterr().out assert "0 active" in out + def test_bridge_status(self, iso, capsys): + with patch("cli._get_service_status", side_effect=[ + self._mock_service(), + self._mock_service(pid="5678"), + ]): + cli.cmd_status(_args()) + out = capsys.readouterr().out + assert "WA Bridge: ONLINE" in out + # --------------------------------------------------------------------------- # cmd_doctor @@ -119,14 +116,13 @@ class TestDoctor: patch("os.statvfs", return_value=stat), patch("subprocess.run", return_value=mock_proc), patch("urllib.request.urlopen", return_value=mock_resp), + patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "123"}), ] if setup_full: # Create .gitignore with required entries gi_path = cli.PROJECT_ROOT / ".gitignore" gi_path.write_text("sessions/\nlogs/\n.env\n*.sqlite\n") - # Create PID file with current PID - iso["pid_file"].write_text(str(_os.getpid())) # Set config.json not world-readable iso["config_file"].chmod(0o600) # Create sessions dir not world-readable @@ -205,33 +201,33 @@ class TestDoctor: class TestRestart: - def test_no_pid_file(self, iso, capsys): - with pytest.raises(SystemExit): - cli.cmd_restart(_args()) - assert "no PID file" in capsys.readouterr().out + def test_restart_success(self, iso, capsys): + with patch("cli._systemctl", return_value=(0, "")), \ + patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "999"}), \ + patch("time.sleep"): + cli.cmd_restart(_args(bridge=False)) + out = capsys.readouterr().out + assert "restarted" in out.lower() + assert "999" in out - def test_invalid_pid(self, iso, capsys): - iso["pid_file"].write_text("nope") - with pytest.raises(SystemExit): - cli.cmd_restart(_args()) - assert "invalid PID" in capsys.readouterr().out - - def test_dead_process(self, iso, capsys): - iso["pid_file"].write_text("99999") - with patch("os.kill", side_effect=OSError): - with pytest.raises(SystemExit): - cli.cmd_restart(_args()) - assert "not running" in capsys.readouterr().out - - def test_sends_sigterm(self, iso, capsys): - iso["pid_file"].write_text("42") + def test_restart_with_bridge(self, iso, capsys): calls = [] - with patch("os.kill", side_effect=lambda p, s: calls.append((p, s))): - cli.cmd_restart(_args()) - assert (42, 0) in calls - assert (42, signal.SIGTERM) in calls - assert "SIGTERM" in capsys.readouterr().out - assert "42" in capsys.readouterr().out or True # already consumed above + def mock_ctl(*args): + calls.append(args) + return (0, "") + with patch("cli._systemctl", side_effect=mock_ctl), \ + patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "100"}), \ + patch("time.sleep"): + cli.cmd_restart(_args(bridge=True)) + # Should have called kill+start for both bridge and core + assert len(calls) == 4 + + def test_restart_fails(self, iso, capsys): + with patch("cli._systemctl", return_value=(0, "")), \ + patch("cli._get_service_status", return_value={"ActiveState": "failed"}), \ + patch("time.sleep"): + with pytest.raises(SystemExit): + cli.cmd_restart(_args(bridge=False)) # ---------------------------------------------------------------------------