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))
|
||||
|
||||
Reference in New Issue
Block a user