diff --git a/HANDOFF.md b/HANDOFF.md index 436cf6c..527363d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,12 +1,12 @@ # Echo Core — Session Handoff -**Data:** 2026-02-13 +**Data:** 2026-02-14 **Proiect:** ~/echo-core/ (inlocuire completa OpenClaw) **Plan complet:** ~/.claude/plans/enumerated-noodling-floyd.md --- -## Status curent: Stage 13 — COMPLET. Toate stages finalizate. +## Status curent: Stage 13 + Setup Wizard — COMPLET. Toate stages finalizate. ### Stages completate (toate committed): - **Stage 1** (f2973aa): Project Bootstrap — structura, git, venv, copiere fisiere din clawd @@ -24,6 +24,7 @@ - **Stage 12** (2d8e56d): Telegram Bot — python-telegram-bot, commands, inline keyboards, concurrent with Discord - **Stage 13** (80502b7 + 624eb09): WhatsApp Bridge — Baileys Node.js bridge + Python adapter, polling, group chat, CLI commands - **Systemd** (6454f0f): Echo Core + WhatsApp bridge as systemd user services, CLI uses systemctl +- **Setup Wizard** (setup.sh): Interactive onboarding — 10-step wizard, idempotent, bridges Discord/Telegram/WhatsApp ### Total teste: 440 PASS (zero failures) @@ -90,6 +91,36 @@ echo restart --- +## Setup Wizard (`setup.sh`): + +Script interactiv de onboarding pentru instalari noi sau reconfigurare. 10 pasi: + +| Step | Ce face | +|------|---------| +| 0. Welcome | ASCII art, detecteaza setup anterior (`.setup-meta.json`) | +| 1. Prerequisites | Python 3.12+ (hard), pip (hard), Claude CLI (hard), Node 22+ / curl / systemctl (warn) | +| 2. Venv | Creeaza `.venv/`, instaleaza `requirements.txt` cu spinner | +| 3. Identity | Bot name, owner Discord ID, admin IDs — citeste defaults din config existent | +| 4. Discord | Token input (masked), valideaza via `/users/@me`, stocheaza in keyring | +| 5. Telegram | Token via BotFather, valideaza via `/getMe`, stocheaza in keyring | +| 6. WhatsApp | Auto-skip daca lipseste Node.js, `npm install`, telefon owner, instructiuni QR | +| 7. Config | Merge inteligent in `config.json` via Python, backup automat cu timestamp | +| 8. Systemd | Genereaza + enable `echo-core.service` + `echo-whatsapp-bridge.service` | +| 9. Health | Valideaza JSON, secrets keyring, dirs writable, Claude CLI, service status | +| 10. Summary | Tabel cu checkmarks, scrie `.setup-meta.json`, next steps | + +**Idempotent:** re-run safe, intreaba "Replace?" (default N) pentru tot ce exista. Backup automat config.json. + +```bash +# Fresh install +cd ~/echo-core && bash setup.sh + +# Re-run (preserva config + secrets existente) +bash setup.sh +``` + +--- + ## Fisiere cheie: | Fisier | Descriere | @@ -107,6 +138,7 @@ echo restart | `src/adapters/whatsapp.py` | WhatsApp adapter — polls Node.js bridge | | `bridge/whatsapp/index.js` | Node.js WhatsApp bridge — Baileys + Express | | `cli.py` | CLI: echo status/doctor/restart/logs/secrets/cron/heartbeat/memory/whatsapp | +| `setup.sh` | Interactive setup wizard — 10-step onboarding, idempotent | | `config.json` | Runtime config (channels, telegram_channels, whatsapp, admins, models) | ## Decizii arhitecturale: diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..07ff83e --- /dev/null +++ b/setup.sh @@ -0,0 +1,1086 @@ +#!/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 + + # 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 "$@"