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>
1097 lines
35 KiB
Bash
Executable File
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 "$@"
|