Files
echo-core/setup.sh
MoltBot Service 21d55cbc6a add eco CLI symlink to setup wizard, document all CLI commands
setup.sh now installs eco → ~/.local/bin/eco (symlink to cli.py).
README.md updated with full eco command reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 06:48:22 +00:00

1097 lines
35 KiB
Bash
Executable File

#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════════════
# Echo Core — Interactive Setup Wizard
# Re-run safe: detects existing venv, config, secrets. Backs up before writing.
# ═══════════════════════════════════════════════════════════════════════════════
set -uo pipefail
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
META_FILE="$SCRIPT_DIR/.setup-meta.json"
VENV_DIR="$SCRIPT_DIR/.venv"
REQUIREMENTS="$SCRIPT_DIR/requirements.txt"
WA_BRIDGE_DIR="$SCRIPT_DIR/bridge/whatsapp"
SYSTEMD_DIR="$HOME/.config/systemd/user"
KEYRING_SERVICE="echo-core"
# State collected during wizard
BOT_NAME=""
OWNER_DISCORD_ID=""
ADMIN_IDS=""
DISCORD_ENABLED=false
DISCORD_TOKEN=""
DISCORD_CHANNEL_ID=""
DISCORD_CHANNEL_ALIAS=""
TELEGRAM_ENABLED=false
TELEGRAM_TOKEN=""
TELEGRAM_ADMIN_IDS=""
WHATSAPP_ENABLED=false
WHATSAPP_OWNER_PHONE=""
NODE_AVAILABLE=false
SERVICES_INSTALLED=false
START_NOW=false
CURRENT_STEP=0
# Prereq results
HAS_PYTHON=false
HAS_PIP=false
HAS_NODE=false
HAS_CLAUDE=false
HAS_CURL=false
HAS_SYSTEMCTL=false
PYTHON_BIN=""
# ---------------------------------------------------------------------------
# Colors & UI
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
C_RESET='\033[0m'
C_BOLD='\033[1m'
C_DIM='\033[2m'
C_ORANGE='\033[38;5;208m'
C_GREEN='\033[38;5;82m'
C_RED='\033[38;5;196m'
C_YELLOW='\033[38;5;220m'
C_CYAN='\033[38;5;45m'
C_GRAY='\033[38;5;245m'
else
C_RESET='' C_BOLD='' C_DIM='' C_ORANGE='' C_GREEN=''
C_RED='' C_YELLOW='' C_CYAN='' C_GRAY=''
fi
info() { echo -e "${C_GRAY} $*${C_RESET}"; }
success() { echo -e "${C_GREEN}$*${C_RESET}"; }
warn() { echo -e "${C_YELLOW}$*${C_RESET}"; }
error() { echo -e "${C_RED}$*${C_RESET}"; }
accent() { echo -e "${C_ORANGE}$*${C_RESET}"; }
dim() { echo -e "${C_DIM}$*${C_RESET}"; }
# Box header for each step
step_header() {
local num="$1" title="$2"
echo ""
echo -e "${C_ORANGE}┌──────────────────────────────────────────────────────────┐${C_RESET}"
echo -e "${C_ORANGE}${C_RESET} ${C_BOLD}Step $num: $title${C_RESET}"
echo -e "${C_ORANGE}└──────────────────────────────────────────────────────────┘${C_RESET}"
}
# Braille spinner
spinner() {
local pid=$1 msg="$2"
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while kill -0 "$pid" 2>/dev/null; do
printf "\r ${C_CYAN}%s${C_RESET} %s" "${frames[$i]}" "$msg"
i=$(( (i + 1) % ${#frames[@]} ))
sleep 0.1
done
printf "\r\033[K"
}
# Prompt with default — returns answer in $REPLY
ask() {
local prompt="$1" default="${2:-}"
if [[ -n "$default" ]]; then
printf " ${C_ORANGE}${C_RESET} %s [${C_DIM}%s${C_RESET}]: " "$prompt" "$default"
else
printf " ${C_ORANGE}${C_RESET} %s: " "$prompt"
fi
read -r REPLY
[[ -z "$REPLY" ]] && REPLY="$default"
}
# Yes/no prompt — sets REPLY to y or n
ask_yn() {
local prompt="$1" default="${2:-y}"
local hint="Y/n"
[[ "$default" == "n" ]] && hint="y/N"
printf " ${C_ORANGE}${C_RESET} %s [%s]: " "$prompt" "$hint"
read -r REPLY
REPLY="${REPLY:-$default}"
REPLY="${REPLY,,}" # lowercase
[[ "$REPLY" == y* ]] && REPLY="y" || REPLY="n"
}
# Secret input (masked)
ask_secret() {
local prompt="$1"
printf " ${C_ORANGE}${C_RESET} %s: " "$prompt"
read -rs REPLY
echo ""
}
# ---------------------------------------------------------------------------
# Ctrl+C handler
# ---------------------------------------------------------------------------
cleanup() {
echo ""
echo -e "${C_YELLOW}Setup interrupted at step $CURRENT_STEP. Re-run anytime to continue.${C_RESET}"
exit 130
}
trap cleanup INT
# Step failure handler — offers to continue
step_failed() {
local step_name="$1"
error "$step_name failed"
ask_yn "Continue with remaining steps?" "y"
[[ "$REPLY" == "n" ]] && exit 1
}
# ---------------------------------------------------------------------------
# Step 0: Welcome
# ---------------------------------------------------------------------------
step_welcome() {
CURRENT_STEP=0
echo ""
echo -e "${C_ORANGE}${C_BOLD}"
cat << 'LOGO'
███████╗ ██████╗██╗ ██╗ ██████╗
██╔════╝██╔════╝██║ ██║██╔═══██╗
█████╗ ██║ ███████║██║ ██║
██╔══╝ ██║ ██╔══██║██║ ██║
███████╗╚██████╗██║ ██║╚██████╔╝
╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝
LOGO
echo -e "${C_RESET}"
echo -e " ${C_BOLD}Echo Core — Interactive Setup Wizard${C_RESET}"
# Show version from HANDOFF.md or git
if command -v git &>/dev/null && git -C "$SCRIPT_DIR" rev-parse --short HEAD &>/dev/null 2>&1; then
local ver
ver=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null)
dim " Version: git@$ver"
fi
echo ""
# Detect previous setup
if [[ -f "$META_FILE" ]]; then
local prev_date
prev_date=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('date','unknown'))" 2>/dev/null || echo "unknown")
warn "Previous setup detected (from $prev_date)"
info "Existing config and secrets will be preserved unless you choose to replace them."
echo ""
fi
if [[ -f "$CONFIG_FILE" ]]; then
info "Existing config.json found — will merge, not overwrite."
fi
echo -e " ${C_DIM}This wizard will guide you through configuring Echo Core.${C_RESET}"
echo -e " ${C_DIM}Press Ctrl+C at any time to abort.${C_RESET}"
echo ""
ask_yn "Ready to begin?" "y"
[[ "$REPLY" == "n" ]] && { echo " Aborted."; exit 0; }
}
# ---------------------------------------------------------------------------
# Step 1: Prerequisites
# ---------------------------------------------------------------------------
step_prerequisites() {
CURRENT_STEP=1
step_header 1 "Prerequisites Check"
local fail=false
# Python 3.12+
if command -v python3 &>/dev/null; then
PYTHON_BIN="$(command -v python3)"
local pyver
pyver=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
local major minor
major=$(echo "$pyver" | cut -d. -f1)
minor=$(echo "$pyver" | cut -d. -f2)
if [[ "$major" -ge 3 && "$minor" -ge 12 ]]; then
success "Python $pyver ($PYTHON_BIN)"
HAS_PYTHON=true
else
error "Python $pyver found — need 3.12+"
fail=true
fi
else
error "Python 3 not found"
fail=true
fi
# pip
if python3 -m pip --version &>/dev/null 2>&1; then
success "pip $(python3 -m pip --version 2>/dev/null | awk '{print $2}')"
HAS_PIP=true
else
error "pip not found (python3 -m pip)"
fail=true
fi
# Node.js 22+ (warn only)
if command -v node &>/dev/null; then
local nodever
nodever=$(node --version 2>/dev/null | sed 's/^v//')
local nodemajor
nodemajor=$(echo "$nodever" | cut -d. -f1)
if [[ "$nodemajor" -ge 22 ]]; then
success "Node.js v$nodever"
HAS_NODE=true
NODE_AVAILABLE=true
else
warn "Node.js v$nodever — need 22+ for WhatsApp bridge"
NODE_AVAILABLE=false
fi
else
warn "Node.js not found (needed only for WhatsApp bridge)"
NODE_AVAILABLE=false
fi
# Claude CLI
if command -v claude &>/dev/null; then
success "Claude CLI found"
HAS_CLAUDE=true
else
error "Claude CLI not found — required for bot operation"
fail=true
fi
# curl (warn)
if command -v curl &>/dev/null; then
success "curl"
HAS_CURL=true
else
warn "curl not found — token validation will be skipped"
fi
# systemctl (warn)
if command -v systemctl &>/dev/null; then
success "systemctl"
HAS_SYSTEMCTL=true
else
warn "systemctl not found — service installation will be skipped"
fi
if $fail; then
error "Required prerequisites missing. Install them and re-run."
exit 1
fi
echo ""
success "All required prerequisites OK"
}
# ---------------------------------------------------------------------------
# Step 2: Python Virtual Environment
# ---------------------------------------------------------------------------
step_venv() {
CURRENT_STEP=2
step_header 2 "Python Virtual Environment"
if [[ -d "$VENV_DIR" && -f "$VENV_DIR/bin/python3" ]]; then
success "Virtual environment exists at .venv/"
ask_yn "Reinstall dependencies from requirements.txt?" "n"
if [[ "$REPLY" == "n" ]]; then
info "Skipping dependency installation."
return 0
fi
else
info "Creating virtual environment..."
if ! python3 -m venv "$VENV_DIR" 2>/dev/null; then
error "Failed to create venv"
step_failed "Venv creation"
return 1
fi
success "Created .venv/"
fi
if [[ ! -f "$REQUIREMENTS" ]]; then
warn "requirements.txt not found — skipping pip install"
return 0
fi
info "Installing dependencies..."
"$VENV_DIR/bin/pip" install --upgrade pip -q &>/dev/null &
spinner $! "Upgrading pip"
success "pip upgraded"
"$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" -q &>/dev/null &
spinner $! "Installing requirements"
if "$VENV_DIR/bin/pip" install -r "$REQUIREMENTS" -q &>/dev/null; then
success "Dependencies installed"
else
error "pip install failed"
step_failed "Dependency installation"
fi
}
# ---------------------------------------------------------------------------
# Step 3: Bot Identity
# ---------------------------------------------------------------------------
step_identity() {
CURRENT_STEP=3
step_header 3 "Bot Identity"
# Read existing values from config
local existing_name="" existing_owner="" existing_admins=""
if [[ -f "$CONFIG_FILE" ]]; then
existing_name=$("$VENV_DIR/bin/python3" -c "
import json
c = json.load(open('$CONFIG_FILE'))
print(c.get('bot',{}).get('name',''))" 2>/dev/null || echo "")
existing_owner=$("$VENV_DIR/bin/python3" -c "
import json
c = json.load(open('$CONFIG_FILE'))
print(c.get('bot',{}).get('owner',''))" 2>/dev/null || echo "")
existing_admins=$("$VENV_DIR/bin/python3" -c "
import json
c = json.load(open('$CONFIG_FILE'))
a = c.get('bot',{}).get('admins',[])
print(','.join(a))" 2>/dev/null || echo "")
fi
ask "Bot name" "${existing_name:-Echo}"
BOT_NAME="$REPLY"
ask "Owner Discord user ID" "$existing_owner"
OWNER_DISCORD_ID="$REPLY"
if [[ -n "$existing_admins" ]]; then
info "Current admin IDs: $existing_admins"
fi
ask "Additional admin IDs (comma-separated, or blank)" "$existing_admins"
ADMIN_IDS="$REPLY"
echo ""
success "Identity: ${C_BOLD}$BOT_NAME${C_RESET}${C_GREEN} (owner: $OWNER_DISCORD_ID)"
}
# ---------------------------------------------------------------------------
# Step 4: Discord Setup
# ---------------------------------------------------------------------------
validate_discord_token() {
local token="$1"
if ! $HAS_CURL; then
warn "curl not available — skipping token validation"
return 0
fi
local response
response=$(curl -sf -H "Authorization: Bot $token" \
"https://discord.com/api/v10/users/@me" 2>/dev/null)
if [[ $? -eq 0 && -n "$response" ]]; then
local botname
botname=$(echo "$response" | "$VENV_DIR/bin/python3" -c "import json,sys; print(json.load(sys.stdin).get('username','?'))" 2>/dev/null || echo "?")
success "Discord token valid — bot: $botname"
return 0
fi
return 1
}
step_discord() {
CURRENT_STEP=4
step_header 4 "Discord Bridge"
ask_yn "Configure Discord bot?" "y"
if [[ "$REPLY" == "n" ]]; then
info "Skipping Discord setup."
return 0
fi
DISCORD_ENABLED=true
# Check existing token in keyring
local existing_token
existing_token=$("$VENV_DIR/bin/python3" -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR')
from src.credential_store import get_secret
t = get_secret('discord_token')
print('yes' if t else 'no')" 2>/dev/null || echo "no")
if [[ "$existing_token" == "yes" ]]; then
success "Discord token already stored in keyring"
ask_yn "Replace existing token?" "n"
if [[ "$REPLY" == "n" ]]; then
info "Keeping existing token."
# Ask for optional channel
_ask_discord_channel
return 0
fi
fi
echo ""
dim " To get a Discord bot token:"
dim " 1. Go to https://discord.com/developers/applications"
dim " 2. Create application → Bot → Reset Token → Copy"
dim " 3. Enable MESSAGE CONTENT intent under Bot → Privileged Intents"
dim " 4. Invite with: OAuth2 → URL Generator → bot + applications.commands"
echo ""
local attempts=0
while [[ $attempts -lt 3 ]]; do
ask_secret "Discord bot token"
DISCORD_TOKEN="$REPLY"
if [[ -z "$DISCORD_TOKEN" ]]; then
warn "Empty token — skipping Discord"
DISCORD_ENABLED=false
return 0
fi
if validate_discord_token "$DISCORD_TOKEN"; then
# Store via Python stdin (not CLI argument)
echo "$DISCORD_TOKEN" | "$VENV_DIR/bin/python3" -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR')
from src.credential_store import set_secret
set_secret('discord_token', sys.stdin.readline().strip())"
success "Token stored in keyring"
break
else
error "Token validation failed"
attempts=$((attempts + 1))
if [[ $attempts -ge 3 ]]; then
warn "3 failed attempts — skipping Discord token"
DISCORD_ENABLED=false
return 0
fi
fi
done
_ask_discord_channel
}
_ask_discord_channel() {
echo ""
ask "Default Discord channel ID (blank to skip)" ""
DISCORD_CHANNEL_ID="$REPLY"
if [[ -n "$DISCORD_CHANNEL_ID" ]]; then
ask "Channel alias" "general"
DISCORD_CHANNEL_ALIAS="$REPLY"
fi
}
# ---------------------------------------------------------------------------
# Step 5: Telegram Setup
# ---------------------------------------------------------------------------
validate_telegram_token() {
local token="$1"
if ! $HAS_CURL; then
warn "curl not available — skipping token validation"
return 0
fi
local response
response=$(curl -sf "https://api.telegram.org/bot${token}/getMe" 2>/dev/null)
if [[ $? -eq 0 ]] && echo "$response" | "$VENV_DIR/bin/python3" -c "import json,sys; d=json.load(sys.stdin); exit(0 if d.get('ok') else 1)" 2>/dev/null; then
local botname
botname=$(echo "$response" | "$VENV_DIR/bin/python3" -c "import json,sys; print(json.load(sys.stdin)['result']['username'])" 2>/dev/null || echo "?")
success "Telegram token valid — bot: @$botname"
return 0
fi
return 1
}
step_telegram() {
CURRENT_STEP=5
step_header 5 "Telegram Bridge"
ask_yn "Configure Telegram bot?" "n"
if [[ "$REPLY" == "n" ]]; then
info "Skipping Telegram setup."
return 0
fi
TELEGRAM_ENABLED=true
# Check existing token
local existing_token
existing_token=$("$VENV_DIR/bin/python3" -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR')
from src.credential_store import get_secret
t = get_secret('telegram_token')
print('yes' if t else 'no')" 2>/dev/null || echo "no")
if [[ "$existing_token" == "yes" ]]; then
success "Telegram token already stored in keyring"
ask_yn "Replace existing token?" "n"
if [[ "$REPLY" == "n" ]]; then
info "Keeping existing token."
_ask_telegram_admins
return 0
fi
fi
echo ""
dim " To get a Telegram bot token:"
dim " 1. Open Telegram, search for @BotFather"
dim " 2. Send /newbot and follow the prompts"
dim " 3. Copy the HTTP API token"
echo ""
local attempts=0
while [[ $attempts -lt 3 ]]; do
ask_secret "Telegram bot token"
local tg_token="$REPLY"
if [[ -z "$tg_token" ]]; then
warn "Empty token — skipping Telegram"
TELEGRAM_ENABLED=false
return 0
fi
if validate_telegram_token "$tg_token"; then
echo "$tg_token" | "$VENV_DIR/bin/python3" -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR')
from src.credential_store import set_secret
set_secret('telegram_token', sys.stdin.readline().strip())"
success "Token stored in keyring"
TELEGRAM_TOKEN="$tg_token"
break
else
error "Token validation failed"
attempts=$((attempts + 1))
if [[ $attempts -ge 3 ]]; then
warn "3 failed attempts — skipping Telegram token"
TELEGRAM_ENABLED=false
return 0
fi
fi
done
_ask_telegram_admins
}
_ask_telegram_admins() {
ask "Telegram admin user IDs (comma-separated, blank for none)" ""
TELEGRAM_ADMIN_IDS="$REPLY"
}
# ---------------------------------------------------------------------------
# Step 6: WhatsApp Setup
# ---------------------------------------------------------------------------
step_whatsapp() {
CURRENT_STEP=6
step_header 6 "WhatsApp Bridge"
if ! $NODE_AVAILABLE; then
info "Node.js 22+ not available — skipping WhatsApp setup."
return 0
fi
ask_yn "Configure WhatsApp bridge?" "n"
if [[ "$REPLY" == "n" ]]; then
info "Skipping WhatsApp setup."
return 0
fi
WHATSAPP_ENABLED=true
# npm install
if [[ -d "$WA_BRIDGE_DIR/node_modules" ]]; then
success "node_modules/ already exists"
ask_yn "Re-run npm install?" "n"
if [[ "$REPLY" == "y" ]]; then
info "Running npm install..."
(cd "$WA_BRIDGE_DIR" && npm install --no-fund --no-audit 2>&1) &>/dev/null &
spinner $! "Installing npm packages"
if (cd "$WA_BRIDGE_DIR" && npm install --no-fund --no-audit &>/dev/null); then
success "npm packages installed"
else
warn "npm install had issues — bridge may still work"
fi
fi
else
info "Running npm install in bridge/whatsapp/..."
(cd "$WA_BRIDGE_DIR" && npm install --no-fund --no-audit 2>&1) &>/dev/null &
spinner $! "Installing npm packages"
if (cd "$WA_BRIDGE_DIR" && npm install --no-fund --no-audit &>/dev/null); then
success "npm packages installed"
else
error "npm install failed"
step_failed "WhatsApp npm install"
return 1
fi
fi
# Owner phone
local existing_phone=""
if [[ -f "$CONFIG_FILE" ]]; then
existing_phone=$("$VENV_DIR/bin/python3" -c "
import json
c = json.load(open('$CONFIG_FILE'))
print(c.get('whatsapp',{}).get('owner',''))" 2>/dev/null || echo "")
fi
ask "Owner phone number (international, no +)" "$existing_phone"
WHATSAPP_OWNER_PHONE="$REPLY"
echo ""
dim " WhatsApp QR pairing will happen when you start the bridge:"
dim " systemctl --user start echo-whatsapp-bridge"
dim " journalctl --user -u echo-whatsapp-bridge -f"
dim " Or open http://127.0.0.1:8098/qr in a browser."
echo ""
success "WhatsApp bridge prepared"
}
# ---------------------------------------------------------------------------
# Step 7: Configuration (merge into config.json)
# ---------------------------------------------------------------------------
step_config() {
CURRENT_STEP=7
step_header 7 "Configuration"
# Backup existing config
if [[ -f "$CONFIG_FILE" ]]; then
local backup="$CONFIG_FILE.bak.$(date +%Y%m%d%H%M%S)"
cp "$CONFIG_FILE" "$backup"
success "Backup: $(basename "$backup")"
fi
# Build patch JSON and merge via Python
"$VENV_DIR/bin/python3" << 'PYEOF' - \
"$CONFIG_FILE" \
"$BOT_NAME" \
"$OWNER_DISCORD_ID" \
"$ADMIN_IDS" \
"$DISCORD_ENABLED" \
"$DISCORD_CHANNEL_ID" \
"$DISCORD_CHANNEL_ALIAS" \
"$TELEGRAM_ENABLED" \
"$TELEGRAM_ADMIN_IDS" \
"$WHATSAPP_ENABLED" \
"$WHATSAPP_OWNER_PHONE"
import json, sys, os
from pathlib import Path
args = sys.argv[1:]
config_path = args[0]
bot_name = args[1]
owner_discord = args[2]
admin_ids_raw = args[3]
discord_on = args[4] == "true"
discord_ch_id = args[5]
discord_ch_alias= args[6]
telegram_on = args[7] == "true"
tg_admin_raw = args[8]
whatsapp_on = args[9] == "true"
wa_phone = args[10]
# Load existing or create empty
if Path(config_path).exists():
with open(config_path) as f:
config = json.load(f)
else:
config = {}
# --- bot section ---
bot = config.setdefault("bot", {})
if bot_name:
bot["name"] = bot_name
bot.setdefault("default_model", "opus")
if owner_discord:
bot["owner"] = owner_discord
if admin_ids_raw:
bot["admins"] = [a.strip() for a in admin_ids_raw.split(",") if a.strip()]
# --- channels (preserve existing) ---
config.setdefault("channels", {})
if discord_on and discord_ch_id and discord_ch_alias:
config["channels"][discord_ch_alias] = {
"id": discord_ch_id,
"default_model": "opus"
}
# --- telegram_channels (preserve existing) ---
config.setdefault("telegram_channels", {})
# --- whatsapp ---
wa = config.setdefault("whatsapp", {})
wa["enabled"] = whatsapp_on
wa.setdefault("bridge_url", "http://127.0.0.1:8098")
if wa_phone:
wa["owner"] = wa_phone
wa.setdefault("admins", [])
config.setdefault("whatsapp_channels", {})
# --- heartbeat ---
hb = config.setdefault("heartbeat", {})
hb.setdefault("enabled", True)
hb.setdefault("interval_minutes", 30)
# --- ollama ---
ol = config.setdefault("ollama", {})
ol.setdefault("url", "http://localhost:11434")
# --- paths ---
paths = config.setdefault("paths", {})
paths.setdefault("personality", "personality/")
paths.setdefault("tools", "tools/")
paths.setdefault("memory", "memory/")
paths.setdefault("logs", "logs/")
paths.setdefault("sessions", "sessions/")
# Write
with open(config_path, "w") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
f.write("\n")
print("OK")
PYEOF
if [[ $? -eq 0 ]]; then
success "config.json written"
else
error "Failed to write config.json"
step_failed "Config write"
fi
}
# ---------------------------------------------------------------------------
# Step 8: Systemd Services
# ---------------------------------------------------------------------------
step_systemd() {
CURRENT_STEP=8
step_header 8 "Systemd Services"
if ! $HAS_SYSTEMCTL; then
info "systemctl not available — skipping service installation."
return 0
fi
mkdir -p "$SYSTEMD_DIR"
# --- echo-core.service ---
local svc_core="$SYSTEMD_DIR/echo-core.service"
local core_changed=false
local core_content
core_content=$(cat << EOF
[Unit]
Description=Echo Core Bot (Discord + Telegram + WhatsApp)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=$SCRIPT_DIR
ExecStart=$VENV_DIR/bin/python3 src/main.py
Restart=always
RestartSec=5
KillMode=mixed
TimeoutStopSec=10
Environment=HOME=$HOME
Environment="PATH=$HOME/.local/bin:$VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin"
[Install]
WantedBy=default.target
EOF
)
if [[ -f "$svc_core" ]]; then
success "echo-core.service exists"
ask_yn "Regenerate echo-core.service?" "n"
[[ "$REPLY" == "y" ]] && core_changed=true
else
core_changed=true
fi
if $core_changed; then
echo "$core_content" > "$svc_core"
success "Wrote echo-core.service"
fi
# --- echo-whatsapp-bridge.service ---
local wa_changed=false
if $WHATSAPP_ENABLED; then
local svc_wa="$SYSTEMD_DIR/echo-whatsapp-bridge.service"
local wa_content
wa_content=$(cat << EOF
[Unit]
Description=Echo Core WhatsApp Bridge (Baileys)
After=network-online.target
Wants=network-online.target
Before=echo-core.service
[Service]
Type=simple
WorkingDirectory=$WA_BRIDGE_DIR
ExecStart=/usr/bin/node index.js
Restart=always
RestartSec=5
KillMode=process
Environment=HOME=$HOME
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
[Install]
WantedBy=default.target
EOF
)
if [[ -f "$svc_wa" ]]; then
success "echo-whatsapp-bridge.service exists"
ask_yn "Regenerate echo-whatsapp-bridge.service?" "n"
[[ "$REPLY" == "y" ]] && wa_changed=true
else
wa_changed=true
fi
if $wa_changed; then
echo "$wa_content" > "$svc_wa"
success "Wrote echo-whatsapp-bridge.service"
fi
fi
# daemon-reload + enable
if $core_changed || $wa_changed; then
systemctl --user daemon-reload
success "daemon-reload done"
fi
systemctl --user enable echo-core.service &>/dev/null
success "echo-core.service enabled"
if $WHATSAPP_ENABLED; then
systemctl --user enable echo-whatsapp-bridge.service &>/dev/null
success "echo-whatsapp-bridge.service enabled"
fi
SERVICES_INSTALLED=true
# Install `eco` CLI command
local cli_target="$HOME/.local/bin/eco"
mkdir -p "$HOME/.local/bin"
if [[ -L "$cli_target" || -f "$cli_target" ]]; then
success "eco CLI already installed at ~/.local/bin/eco"
else
ln -s "$SCRIPT_DIR/cli.py" "$cli_target"
success "Installed eco CLI → ~/.local/bin/eco"
fi
# Start now?
echo ""
ask_yn "Start services now?" "n"
if [[ "$REPLY" == "y" ]]; then
START_NOW=true
if $WHATSAPP_ENABLED; then
systemctl --user start echo-whatsapp-bridge.service
info "Starting WhatsApp bridge..."
sleep 2
fi
systemctl --user start echo-core.service
info "Starting Echo Core..."
sleep 2
local status
status=$(systemctl --user is-active echo-core.service 2>/dev/null)
if [[ "$status" == "active" ]]; then
success "echo-core.service is running"
else
warn "echo-core.service status: $status"
fi
if $WHATSAPP_ENABLED; then
status=$(systemctl --user is-active echo-whatsapp-bridge.service 2>/dev/null)
if [[ "$status" == "active" ]]; then
success "echo-whatsapp-bridge.service is running"
else
warn "echo-whatsapp-bridge.service status: $status"
fi
fi
fi
}
# ---------------------------------------------------------------------------
# Step 9: Health Check
# ---------------------------------------------------------------------------
step_healthcheck() {
CURRENT_STEP=9
step_header 9 "Health Check"
local issues=0
# Config valid JSON
if [[ -f "$CONFIG_FILE" ]] && "$VENV_DIR/bin/python3" -c "import json; json.load(open('$CONFIG_FILE'))" 2>/dev/null; then
success "config.json is valid JSON"
else
error "config.json is missing or invalid"
issues=$((issues + 1))
fi
# Secrets in keyring
local secrets_report
secrets_report=$("$VENV_DIR/bin/python3" -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR')
from src.credential_store import get_secret
dt = 'yes' if get_secret('discord_token') else 'no'
tt = 'yes' if get_secret('telegram_token') else 'no'
print(f'{dt},{tt}')" 2>/dev/null || echo "no,no")
local dt tt
dt=$(echo "$secrets_report" | cut -d, -f1)
tt=$(echo "$secrets_report" | cut -d, -f2)
if $DISCORD_ENABLED; then
if [[ "$dt" == "yes" ]]; then
success "Discord token in keyring"
else
error "Discord token NOT in keyring"
issues=$((issues + 1))
fi
fi
if $TELEGRAM_ENABLED; then
if [[ "$tt" == "yes" ]]; then
success "Telegram token in keyring"
else
error "Telegram token NOT in keyring"
issues=$((issues + 1))
fi
fi
# Directories writable
for d in logs sessions memory; do
local full="$SCRIPT_DIR/$d"
if [[ -d "$full" && -w "$full" ]]; then
success "$d/ writable"
elif [[ ! -d "$full" ]]; then
mkdir -p "$full" 2>/dev/null
if [[ -d "$full" ]]; then
success "$d/ created"
else
error "Cannot create $d/"
issues=$((issues + 1))
fi
else
error "$d/ not writable"
issues=$((issues + 1))
fi
done
# Claude CLI
if $HAS_CLAUDE; then
success "Claude CLI available"
else
error "Claude CLI not found"
issues=$((issues + 1))
fi
# Service status (if installed)
if $SERVICES_INSTALLED && $HAS_SYSTEMCTL; then
local core_status
core_status=$(systemctl --user is-active echo-core.service 2>/dev/null || echo "not-found")
if [[ "$core_status" == "active" ]]; then
success "echo-core.service: active"
elif [[ "$core_status" == "inactive" ]]; then
info "echo-core.service: inactive (not started)"
else
warn "echo-core.service: $core_status"
fi
fi
echo ""
if [[ $issues -eq 0 ]]; then
success "All health checks passed"
else
warn "$issues issue(s) found"
fi
}
# ---------------------------------------------------------------------------
# Step 10: Summary
# ---------------------------------------------------------------------------
step_summary() {
CURRENT_STEP=10
step_header 10 "Summary"
local check="${C_GREEN}${C_RESET}"
local cross="${C_RED}${C_RESET}"
echo ""
echo -e "${C_ORANGE}┌────────────────────────────────────────┐${C_RESET}"
echo -e "${C_ORANGE}${C_RESET} ${C_BOLD}Echo Core Setup Complete${C_RESET} ${C_ORANGE}${C_RESET}"
echo -e "${C_ORANGE}├────────────────────────────────────────┤${C_RESET}"
printf "${C_ORANGE}${C_RESET} Bot name: %-23s ${C_ORANGE}${C_RESET}\n" "$BOT_NAME"
printf "${C_ORANGE}${C_RESET} Owner: %-23s ${C_ORANGE}${C_RESET}\n" "$OWNER_DISCORD_ID"
echo -e "${C_ORANGE}├────────────────────────────────────────┤${C_RESET}"
echo -e "${C_ORANGE}${C_RESET} Bridges: ${C_ORANGE}${C_RESET}"
if $DISCORD_ENABLED; then
echo -e "${C_ORANGE}${C_RESET} $check Discord ${C_ORANGE}${C_RESET}"
else
echo -e "${C_ORANGE}${C_RESET} $cross Discord (skipped) ${C_ORANGE}${C_RESET}"
fi
if $TELEGRAM_ENABLED; then
echo -e "${C_ORANGE}${C_RESET} $check Telegram ${C_ORANGE}${C_RESET}"
else
echo -e "${C_ORANGE}${C_RESET} $cross Telegram (skipped) ${C_ORANGE}${C_RESET}"
fi
if $WHATSAPP_ENABLED; then
echo -e "${C_ORANGE}${C_RESET} $check WhatsApp ${C_ORANGE}${C_RESET}"
else
echo -e "${C_ORANGE}${C_RESET} $cross WhatsApp (skipped) ${C_ORANGE}${C_RESET}"
fi
echo -e "${C_ORANGE}├────────────────────────────────────────┤${C_RESET}"
if $SERVICES_INSTALLED; then
echo -e "${C_ORANGE}${C_RESET} $check Systemd services installed ${C_ORANGE}${C_RESET}"
else
echo -e "${C_ORANGE}${C_RESET} $cross Systemd services (skipped) ${C_ORANGE}${C_RESET}"
fi
echo -e "${C_ORANGE}└────────────────────────────────────────┘${C_RESET}"
# Write .setup-meta.json
"$VENV_DIR/bin/python3" << METAEOF
import json
from datetime import datetime
meta = {
"date": datetime.now().isoformat(),
"bot_name": "$BOT_NAME",
"discord": $($DISCORD_ENABLED && echo "True" || echo "False"),
"telegram": $($TELEGRAM_ENABLED && echo "True" || echo "False"),
"whatsapp": $($WHATSAPP_ENABLED && echo "True" || echo "False"),
"services_installed": $($SERVICES_INSTALLED && echo "True" || echo "False")
}
with open("$META_FILE", "w") as f:
json.dump(meta, f, indent=2)
f.write("\n")
METAEOF
success "Wrote .setup-meta.json"
# Next steps
echo ""
echo -e " ${C_BOLD}Next steps:${C_RESET}"
echo ""
if ! $START_NOW && $SERVICES_INSTALLED; then
dim " Start the bot:"
echo -e " ${C_CYAN}systemctl --user start echo-core${C_RESET}"
echo ""
fi
if $WHATSAPP_ENABLED && ! $START_NOW; then
dim " Start WhatsApp bridge + pair via QR:"
echo -e " ${C_CYAN}systemctl --user start echo-whatsapp-bridge${C_RESET}"
echo -e " ${C_CYAN}curl http://127.0.0.1:8098/status${C_RESET}"
echo ""
fi
dim " View logs:"
echo -e " ${C_CYAN}journalctl --user -u echo-core -f${C_RESET}"
echo ""
dim " Check status:"
echo -e " ${C_CYAN}./cli.py status${C_RESET}"
echo -e " ${C_CYAN}./cli.py doctor${C_RESET}"
echo ""
dim " Manage secrets:"
echo -e " ${C_CYAN}./cli.py secrets list${C_RESET}"
echo ""
echo -e " ${C_GREEN}${C_BOLD}Setup complete! 🎉${C_RESET}"
echo ""
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
step_welcome
step_prerequisites
step_venv
step_identity
step_discord
step_telegram
step_whatsapp
step_config
step_systemd
step_healthcheck
step_summary
}
main "$@"