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:
170
cli.py
170
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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user