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 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 22:41:56 +00:00
parent 624eb095f1
commit 6454f0f83c
2 changed files with 157 additions and 103 deletions

170
cli.py
View File

@@ -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))

View File

@@ -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))
# ---------------------------------------------------------------------------