From 4f620a0bbc11f41f241145a8dad3f70989507559 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Thu, 2 Jul 2026 12:28:26 +1000 Subject: [PATCH] Add WhatsApp dashboard pairing flow --- hermes_cli/web_server.py | 535 ++++++++++++++++++- plugins/platforms/whatsapp/adapter.py | 8 +- scripts/whatsapp-bridge/bridge.js | 163 ++++-- tests/hermes_cli/test_whatsapp_onboarding.py | 323 +++++++++++ web/src/lib/api.ts | 71 +++ web/src/pages/ChannelsPage.tsx | 426 +++++++++++++++ 6 files changed, 1485 insertions(+), 41 deletions(-) create mode 100644 tests/hermes_cli/test_whatsapp_onboarding.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 64f1e684e70..30c86e9e90a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -873,6 +873,18 @@ class TelegramOnboardingApply(BaseModel): profile: Optional[str] = None +class WhatsAppOnboardingStart(BaseModel): + mode: Optional[str] = "bot" + allowed_users: Optional[str] = "" + profile: Optional[str] = None + + +class WhatsAppOnboardingApply(BaseModel): + mode: Optional[str] = None + allowed_users: Optional[str] = None + profile: Optional[str] = None + + class AudioTranscriptionRequest(BaseModel): data_url: str mime_type: Optional[str] = None @@ -6111,7 +6123,12 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "name": "WhatsApp", "description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.", "docs_url": "https://github.com/tulir/whatsmeow", - "env_vars": ("WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS"), + "env_vars": ( + "WHATSAPP_ENABLED", + "WHATSAPP_MODE", + "WHATSAPP_DM_POLICY", + "WHATSAPP_ALLOWED_USERS", + ), "required_env": (), }, "homeassistant": { @@ -6305,6 +6322,11 @@ _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { "prompt": "WhatsApp mode", "advanced": True, }, + "WHATSAPP_DM_POLICY": { + "description": "How WhatsApp direct messages are authorized", + "prompt": "WhatsApp DM policy", + "advanced": True, + }, "WHATSAPP_ALLOWED_USERS": { "description": "Comma-separated WhatsApp users allowed to use the bot", "prompt": "Allowed WhatsApp users", @@ -6701,7 +6723,23 @@ def _messaging_platform_payload( error_code = error_code or "startup_failed" error_message = error_message or runtime_gateway_error - return { + whatsapp_setup = None + if platform_id == "whatsapp": + whatsapp_mode = ( + env_on_disk.get("WHATSAPP_MODE") + or ("" if scoped else os.getenv("WHATSAPP_MODE", "")) + ).strip() + allowed_users_value = ( + env_on_disk.get("WHATSAPP_ALLOWED_USERS") + or ("" if scoped else os.getenv("WHATSAPP_ALLOWED_USERS", "")) + ).strip() + whatsapp_setup = { + "mode": whatsapp_mode if whatsapp_mode in {"bot", "self-chat"} else "", + "allowed_users_set": bool(allowed_users_value), + "home_channel_set": bool(home_channel), + } + + payload = { "id": platform_id, "name": entry["name"], "description": entry["description"], @@ -6720,12 +6758,505 @@ def _messaging_platform_payload( "home_channel": home_channel, "env_vars": env_vars, } + if whatsapp_setup is not None: + payload["whatsapp_setup"] = whatsapp_setup + return payload def _write_platform_enabled(platform_id: str, enabled: bool) -> None: write_platform_config_field(platform_id, "enabled", enabled) +_WHATSAPP_ONBOARDING_TTL_SECONDS = 600 +_WHATSAPP_ONBOARDING_TERMINAL_STATUSES = {"connected", "error", "expired", "cancelled"} + + +@dataclass +class _WhatsAppOnboardingSession: + proc: subprocess.Popen | None + mode: str + allowed_users: str + session_path: str + expires_at: str + expires_at_ts: float + profile: str | None = None + status: str = "starting" + qr_payload: str | None = None + account_id: str | None = None + account_name: str | None = None + account_phone: str | None = None + error: str | None = None + + +_whatsapp_onboarding_sessions: dict[str, _WhatsAppOnboardingSession] = {} +_whatsapp_onboarding_lock = threading.RLock() + + +def _utc_iso_from_ts(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z") + + +def _normalize_whatsapp_onboarding_mode(value: Any) -> str: + mode = str(value or "bot").strip().lower() + if mode not in {"bot", "self-chat"}: + raise HTTPException(status_code=400, detail="WhatsApp mode must be 'bot' or 'self-chat'.") + return mode + + +def _normalize_whatsapp_allowed_users(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + return ",".join(part.replace(" ", "") for part in raw.split(",") if part.strip()) + + +def _whatsapp_session_path() -> Path: + from hermes_constants import get_hermes_dir + + return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") + + +def _whatsapp_phone_from_identifier(value: Any) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + candidate = raw.split("@", 1)[0].split(":", 1)[0] + digits = re.sub(r"\D+", "", candidate) + return digits or None + + +def _whatsapp_linked_account_from_session(session_path: Path) -> tuple[str | None, str | None, str | None]: + creds_path = session_path / "creds.json" + try: + payload = json.loads(creds_path.read_text(encoding="utf-8")) + except Exception: + return None, None, None + + account_id: str | None = None + account_name: str | None = None + + def collect(candidate: Any) -> None: + nonlocal account_id, account_name + if not isinstance(candidate, dict): + return + if account_id is None: + for key in ("id", "jid", "lid"): + value = str(candidate.get(key) or "").strip() + if value: + account_id = value + break + if account_name is None: + for key in ("name", "verifiedName", "notify", "pushName"): + value = str(candidate.get(key) or "").strip() + if value: + account_name = value + break + + collect(payload.get("me")) + collect(payload.get("account")) + collect(payload) + return account_id, account_name, _whatsapp_phone_from_identifier(account_id) + + +def _ensure_whatsapp_bridge_dependencies(bridge_dir: Path) -> None: + """Install bridge dependencies when the dashboard is the setup surface.""" + if (bridge_dir / "node_modules").exists(): + return + + from hermes_constants import find_node_executable, with_hermes_node_path + from utils import env_int + + npm = find_node_executable("npm") + if not npm: + raise HTTPException( + status_code=500, + detail="npm was not found. WhatsApp setup needs Node.js and npm.", + ) + + timeout = env_int("WHATSAPP_NPM_INSTALL_TIMEOUT", 300) + try: + result = subprocess.run( + [npm, "install", "--silent"], + cwd=str(bridge_dir), + capture_output=True, + text=True, + timeout=timeout, + env=with_hermes_node_path(), + creationflags=windows_hide_flags(), + ) + except subprocess.TimeoutExpired as exc: + raise HTTPException( + status_code=500, + detail="Installing WhatsApp bridge dependencies timed out.", + ) from exc + except OSError as exc: + raise HTTPException( + status_code=500, + detail=f"Failed to install WhatsApp bridge dependencies: {exc}", + ) from exc + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() + if detail: + detail = "\n".join(detail.splitlines()[-10:]) + raise HTTPException( + status_code=500, + detail=f"npm install failed for WhatsApp bridge: {detail or 'no output'}", + ) + + +def _spawn_whatsapp_pairing_process(session_path: Path, mode: str) -> subprocess.Popen: + from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir + from hermes_constants import find_node_executable, with_hermes_node_path + + bridge_dir = resolve_whatsapp_bridge_dir() + bridge_script = bridge_dir / "bridge.js" + if not bridge_script.exists(): + raise HTTPException( + status_code=500, + detail=f"WhatsApp bridge script was not found at {bridge_script}.", + ) + node = find_node_executable("node") + if not node: + raise HTTPException( + status_code=500, + detail="Node.js was not found. WhatsApp setup needs Node.js.", + ) + + _ensure_whatsapp_bridge_dependencies(bridge_dir) + session_path.mkdir(parents=True, exist_ok=True) + + env = with_hermes_node_path() + env["WHATSAPP_MODE"] = mode + env["WHATSAPP_DM_POLICY"] = "pairing" + return subprocess.Popen( + [ + node, + str(bridge_script), + "--pair-only", + "--pair-json", + "--session", + str(session_path), + ], + cwd=str(bridge_dir), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + start_new_session=True, + env=env, + creationflags=windows_hide_flags(), + ) + + +def _terminate_whatsapp_pairing(proc: subprocess.Popen | None) -> None: + if proc is None: + return + if proc.poll() is not None: + return + try: + proc.terminate() + proc.wait(timeout=3) + except Exception: + try: + proc.kill() + except Exception: + pass + + +def _watch_whatsapp_pairing(pairing_id: str, proc: subprocess.Popen) -> None: + try: + stream = proc.stdout + if stream is not None: + for line in stream: + raw = line.strip() + if not raw: + continue + try: + payload = json.loads(raw) + except json.JSONDecodeError: + continue + event = str(payload.get("event") or "").strip() + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.proc is not proc: + return + if event == "qr": + qr = str(payload.get("qr") or "").strip() + if qr: + record.qr_payload = qr + record.status = "waiting" + record.error = None + elif event == "connected": + user = payload.get("user") + if isinstance(user, dict): + account_id = str(user.get("id") or "").strip() + account_name = str(user.get("name") or "").strip() + record.account_id = account_id or None + record.account_name = account_name or None + record.account_phone = _whatsapp_phone_from_identifier(account_id) + record.status = "connected" + record.error = None + elif event == "error": + record.status = "error" + record.error = str(payload.get("error") or "WhatsApp pairing failed.") + elif event == "disconnected" and record.status == "starting": + record.status = "waiting" + returncode = proc.wait() + except Exception as exc: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if record and record.proc is proc and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + record.status = "error" + record.error = str(exc) + return + + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.proc is not proc: + return + if record.status in {"connected", "cancelled", "expired"}: + return + record.status = "error" + record.error = ( + "WhatsApp pairing process exited before pairing completed." + if returncode == 0 + else f"WhatsApp pairing process exited with code {returncode}." + ) + + +def _run_whatsapp_pairing(pairing_id: str, session_path: Path, mode: str) -> None: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + return + record.status = "installing" + + try: + proc = _spawn_whatsapp_pairing_process(session_path, mode) + except Exception as exc: + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if record and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + record.status = "error" + record.error = str(exc) + return + + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record or record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + _terminate_whatsapp_pairing(proc) + return + record.proc = proc + record.status = "starting" + + _watch_whatsapp_pairing(pairing_id, proc) + + +def _prune_whatsapp_onboarding_sessions() -> None: + now = time.time() + remove_ids: list[str] = [] + for pairing_id, record in _whatsapp_onboarding_sessions.items(): + if ( + record.proc is not None + and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES + and record.proc.poll() is not None + ): + record.status = "error" + record.error = "WhatsApp pairing process exited before pairing completed." + if record.expires_at_ts <= now and record.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + _terminate_whatsapp_pairing(record.proc) + record.status = "expired" + record.error = "WhatsApp QR setup expired. Start a new setup." + if record.status in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES and record.expires_at_ts + 300 <= now: + remove_ids.append(pairing_id) + for pairing_id in remove_ids: + _whatsapp_onboarding_sessions.pop(pairing_id, None) + + +def _supersede_whatsapp_onboarding_sessions(session_path: Path) -> None: + for existing in _whatsapp_onboarding_sessions.values(): + if existing.session_path == str(session_path) and existing.status not in _WHATSAPP_ONBOARDING_TERMINAL_STATUSES: + existing.status = "cancelled" + existing.error = "Superseded by a newer WhatsApp setup session." + _terminate_whatsapp_pairing(existing.proc) + + +def _whatsapp_onboarding_payload(pairing_id: str, record: _WhatsAppOnboardingSession) -> dict[str, Any]: + return { + "pairing_id": pairing_id, + "status": record.status, + "qr_payload": record.qr_payload, + "expires_at": record.expires_at, + "mode": record.mode, + "allowed_users": record.allowed_users, + "account_id": record.account_id, + "account_name": record.account_name, + "account_phone": record.account_phone, + "error": record.error, + } + + +def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) -> dict[str, Any]: + try: + proc, reused = _spawn_gateway_restart(profile) + except Exception as exc: + _log.exception("Failed to auto-restart gateway after WhatsApp onboarding") + return { + "restart_started": False, + "restart_error": str(exc), + } + if reused: + _log.info( + "WhatsApp onboarding: reusing in-flight gateway restart (pid %s)", + proc.pid, + ) + return { + "restart_started": True, + "restart_action": "gateway-restart", + "restart_pid": proc.pid, + } + + +@app.post("/api/messaging/whatsapp/onboarding/start") +async def start_whatsapp_onboarding(body: WhatsAppOnboardingStart): + mode = _normalize_whatsapp_onboarding_mode(body.mode) + allowed_users = _normalize_whatsapp_allowed_users(body.allowed_users) + effective_profile = body.profile + + with _config_profile_scope(effective_profile): + session_path = _whatsapp_session_path() + expires_at_ts = time.time() + _WHATSAPP_ONBOARDING_TTL_SECONDS + expires_at = _utc_iso_from_ts(expires_at_ts) + if (session_path / "creds.json").exists(): + pairing_id = secrets.token_urlsafe(16) + account_id, account_name, account_phone = _whatsapp_linked_account_from_session(session_path) + record = _WhatsAppOnboardingSession( + proc=None, + mode=mode, + allowed_users=allowed_users, + session_path=str(session_path), + expires_at=expires_at, + expires_at_ts=expires_at_ts, + profile=effective_profile, + status="connected", + account_id=account_id, + account_name=account_name, + account_phone=account_phone, + ) + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + _supersede_whatsapp_onboarding_sessions(session_path) + _whatsapp_onboarding_sessions[pairing_id] = record + return _whatsapp_onboarding_payload(pairing_id, record) + + pairing_id = secrets.token_urlsafe(16) + record = _WhatsAppOnboardingSession( + proc=None, + mode=mode, + allowed_users=allowed_users, + session_path=str(session_path), + expires_at=expires_at, + expires_at_ts=expires_at_ts, + profile=effective_profile, + ) + + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + _supersede_whatsapp_onboarding_sessions(session_path) + _whatsapp_onboarding_sessions[pairing_id] = record + + threading.Thread( + target=_run_whatsapp_pairing, + args=(pairing_id, session_path, mode), + daemon=True, + ).start() + + return _whatsapp_onboarding_payload(pairing_id, record) + + +@app.get("/api/messaging/whatsapp/onboarding/{pairing_id}") +async def get_whatsapp_onboarding_status(pairing_id: str): + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="WhatsApp setup session was not found. Start a new setup.", + ) + if record.status == "expired": + raise HTTPException(status_code=410, detail=record.error or "WhatsApp setup expired.") + return _whatsapp_onboarding_payload(pairing_id, record) + + +@app.post("/api/messaging/whatsapp/onboarding/{pairing_id}/apply") +async def apply_whatsapp_onboarding( + pairing_id: str, body: WhatsAppOnboardingApply, profile: Optional[str] = None +): + with _whatsapp_onboarding_lock: + _prune_whatsapp_onboarding_sessions() + record = _whatsapp_onboarding_sessions.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="WhatsApp setup session was not found. Start a new setup.", + ) + if record.status != "connected": + raise HTTPException(status_code=409, detail="WhatsApp setup is not connected yet.") + mode = _normalize_whatsapp_onboarding_mode(body.mode or record.mode) + allowed_users = _normalize_whatsapp_allowed_users( + record.allowed_users if body.allowed_users is None else body.allowed_users + ) + if mode == "self-chat" and not allowed_users: + allowed_users = record.account_phone or record.account_id or "" + record_profile = record.profile + + effective_profile = body.profile or profile or record_profile + try: + with _config_profile_scope(effective_profile): + save_env_value("WHATSAPP_MODE", mode) + save_env_value("WHATSAPP_DM_POLICY", "pairing") + if allowed_users: + save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users) + # Blank means "keep the existing allowlist"; explicit clearing + # still lives in the normal config editor where the field is visible. + save_env_value("WHATSAPP_ENABLED", "true") + _write_platform_enabled("whatsapp", True) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + _log.exception("WhatsApp onboarding apply failed") + raise HTTPException( + status_code=500, + detail="Failed to save WhatsApp setup.", + ) from exc + + with _whatsapp_onboarding_lock: + _whatsapp_onboarding_sessions.pop(pairing_id, None) + + restart_result = _restart_gateway_after_whatsapp_onboarding(effective_profile) + return { + "ok": True, + "platform": "whatsapp", + "needs_restart": not restart_result["restart_started"], + **restart_result, + } + + +@app.delete("/api/messaging/whatsapp/onboarding/{pairing_id}") +async def cancel_whatsapp_onboarding(pairing_id: str): + with _whatsapp_onboarding_lock: + record = _whatsapp_onboarding_sessions.pop(pairing_id, None) + if record: + record.status = "cancelled" + _terminate_whatsapp_pairing(record.proc) + return {"ok": True} + + _TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" _TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{__version__}" _TELEGRAM_USER_ID_RE = re.compile(r"^\d+$") diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 82f08199631..e79b2d8fe3d 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -490,19 +490,19 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # QR codes to its log file and never reaches status:connected, # so every gateway restart paid the 30s timeout + queued WhatsApp # for indefinite retries. Mark non-retryable so the user gets a - # clear "run hermes whatsapp" message instead of the watcher + # clear pairing message instead of the watcher # silently hammering an unconfigured platform. creds_path = self._session_path / "creds.json" if not creds_path.exists(): logger.warning( "[%s] WhatsApp is enabled but not paired (no creds.json at %s). " - "Run `hermes whatsapp` to pair, or remove WHATSAPP_ENABLED from " - "your .env to disable.", + "Pair from the dashboard or run `hermes whatsapp`; remove " + "WHATSAPP_ENABLED from your .env to disable.", self.name, creds_path, ) self._set_fatal_error( "whatsapp_not_paired", - "WhatsApp enabled but not paired — run `hermes whatsapp` to pair.", + "WhatsApp enabled but not paired — pair from the dashboard or run `hermes whatsapp`.", retryable=False, ) return False diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 658de75e6c3..4b5733d16f8 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -100,7 +100,9 @@ try { .slice(0, 16); } catch {} const PAIR_ONLY = args.includes('--pair-only'); +const PAIR_JSON = args.includes('--pair-json'); const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat" +const WHATSAPP_DM_POLICY = String(process.env.WHATSAPP_DM_POLICY || 'open').trim().toLowerCase(); const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || ''); const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n'; const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined @@ -197,6 +199,23 @@ function normalizeWhatsAppId(value) { return String(value).replace(':', '@'); } +function redactWhatsAppId(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + const [userPart, domainPart = ''] = raw.split('@', 2); + const bare = userPart.split(':', 1)[0]; + const digits = bare.replace(/\D/g, ''); + const suffix = digits ? digits.slice(-4) : bare.slice(-4); + return `${suffix ? `…${suffix}` : '…'}${domainPart ? `@${domainPart}` : ''}`; +} + +function emitDebugEvent(payload) { + if (!WHATSAPP_DEBUG) return; + try { + console.log(JSON.stringify({ event: 'debug', ...payload })); + } catch {} +} + function getMessageContent(msg) { const content = msg?.message || {}; if (content.ephemeralMessage?.message) return content.ephemeralMessage.message; @@ -360,6 +379,13 @@ function rememberSentId(id) { let sock = null; let connectionState = 'disconnected'; +function emitPairEvent(event) { + if (!PAIR_JSON) return; + try { + console.log(JSON.stringify({ ts: Date.now(), ...event })); + } catch {} +} + async function startSocket() { const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR); const { version } = await fetchLatestBaileysVersion(); @@ -387,9 +413,13 @@ async function startSocket() { const { connection, lastDisconnect, qr } = update; if (qr) { - console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n'); - qrcode.generate(qr, { small: true }); - console.log('\nWaiting for scan...\n'); + if (PAIR_JSON) { + emitPairEvent({ event: 'qr', qr }); + } else { + console.log('\n📱 Scan this QR code with WhatsApp on your phone:\n'); + qrcode.generate(qr, { small: true }); + console.log('\nWaiting for scan...\n'); + } } if (connection === 'close') { @@ -397,22 +427,39 @@ async function startSocket() { connectionState = 'disconnected'; if (reason === DisconnectReason.loggedOut) { - console.log('❌ Logged out. Delete session and restart to re-authenticate.'); + emitPairEvent({ event: 'error', error: 'logged_out', reason }); + if (!PAIR_JSON) { + console.log('❌ Logged out. Delete session and restart to re-authenticate.'); + } process.exit(1); } else { // 515 = restart requested (common after pairing). Always reconnect. - if (reason === 515) { - console.log('↻ WhatsApp requested restart (code 515). Reconnecting...'); - } else { - console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`); + emitPairEvent({ event: 'disconnected', reason }); + if (!PAIR_JSON) { + if (reason === 515) { + console.log('↻ WhatsApp requested restart (code 515). Reconnecting...'); + } else { + console.log(`⚠️ Connection closed (reason: ${reason}). Reconnecting in 3s...`); + } } setTimeout(startSocket, reason === 515 ? 1000 : 3000); } } else if (connection === 'open') { connectionState = 'connected'; - console.log('✅ WhatsApp connected!'); + const connectedUser = sock?.user + ? { + id: sock.user.id || null, + name: sock.user.name || sock.user.verifiedName || null, + } + : null; + emitPairEvent({ event: 'connected', user: connectedUser }); + if (!PAIR_JSON) { + console.log('✅ WhatsApp connected!'); + } if (PAIR_ONLY) { - console.log('✅ Pairing complete. Credentials saved.'); + if (!PAIR_JSON) { + console.log('✅ Pairing complete. Credentials saved.'); + } // Give Baileys a moment to flush creds, then exit cleanly setTimeout(() => process.exit(0), 2000); } @@ -484,24 +531,29 @@ async function startSocket() { if (!msg.message) continue; const chatId = msg.key.remoteJid; - if (WHATSAPP_DEBUG) { - try { - console.log(JSON.stringify({ - event: 'upsert', type, - fromMe: !!msg.key.fromMe, chatId, - senderId: msg.key.participant || chatId, - messageKeys: Object.keys(msg.message || {}), - })); - } catch {} - } const senderId = msg.key.participant || chatId; const isGroup = chatId.endsWith('@g.us'); const senderNumber = senderId.replace(/@.*/, ''); + emitDebugEvent({ + stage: 'upsert', + type, + fromMe: !!msg.key.fromMe, + chatId: redactWhatsAppId(chatId), + senderId: redactWhatsAppId(senderId), + messageKeys: Object.keys(msg.message || {}), + }); // Handle fromMe messages based on mode let fromOwner = false; if (msg.key.fromMe) { - if (isGroup || chatId.includes('status')) continue; + if (isGroup || chatId.includes('status')) { + emitDebugEvent({ + stage: 'ignored', + reason: isGroup ? 'from_me_group' : 'from_me_status', + chatId: redactWhatsAppId(chatId), + }); + continue; + } if (WHATSAPP_MODE === 'bot') { // Bot mode: separate bot number. fromMe inbound is either @@ -546,7 +598,22 @@ async function startSocket() { const myLid = (sock.user?.lid || '').replace(/:.*@/, '@').replace(/@.*/, ''); const chatNumber = chatId.replace(/@.*/, ''); const isSelfChat = (myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid); - if (!isSelfChat) continue; + emitDebugEvent({ + stage: 'self_chat_check', + matched: !!isSelfChat, + chatId: redactWhatsAppId(chatId), + accountId: redactWhatsAppId(sock.user?.id), + accountLid: redactWhatsAppId(sock.user?.lid), + }); + if (!isSelfChat) { + emitDebugEvent({ + stage: 'ignored', + reason: 'self_chat_mismatch', + chatId: redactWhatsAppId(chatId), + senderId: redactWhatsAppId(senderId), + }); + continue; + } } } @@ -567,7 +634,7 @@ async function startSocket() { } catch {} continue; } - if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + if (WHATSAPP_DM_POLICY !== 'pairing' && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { try { console.log(JSON.stringify({ event: 'ignored', @@ -659,25 +726,39 @@ async function startSocket() { // Ignore Hermes' own reply messages in self-chat mode to avoid loops. if (msg.key.fromMe && ((REPLY_PREFIX && event.body.startsWith(REPLY_PREFIX)) || recentlySentIds.has(msg.key.id))) { if (WHATSAPP_DEBUG) { - try { console.log(JSON.stringify({ event: 'ignored', reason: 'agent_echo', chatId, messageId: msg.key.id })); } catch {} + emitDebugEvent({ + stage: 'ignored', + reason: 'agent_echo', + chatId: redactWhatsAppId(chatId), + messageId: msg.key.id, + }); } continue; } // Skip empty messages if (!event.body && !event.hasMedia) { - if (WHATSAPP_DEBUG) { - try { - console.log(JSON.stringify({ event: 'ignored', reason: 'empty', chatId, messageKeys: Object.keys(msg.message || {}) })); - } catch (err) { - console.error('Failed to log empty message event:', err); - } - } + emitDebugEvent({ + stage: 'ignored', + reason: 'empty', + chatId: redactWhatsAppId(chatId), + messageKeys: Object.keys(msg.message || {}), + }); continue; } messageStore.remember(msg); messageQueue.push(event); + emitDebugEvent({ + stage: 'queued', + chatId: redactWhatsAppId(chatId), + senderId: redactWhatsAppId(senderId), + fromOwner: !!fromOwner, + bodyLength: event.body.length, + hasMedia: event.hasMedia, + mediaType: event.mediaType, + queueLength: messageQueue.length, + }); if (messageQueue.length > MAX_QUEUE_SIZE) { messageQueue.shift(); } @@ -999,10 +1080,20 @@ app.get('/health', (req, res) => { // Start if (PAIR_ONLY) { // Pair-only mode: just connect, show QR, save creds, exit. No HTTP server. - console.log('📱 WhatsApp pairing mode'); - console.log(`📁 Session: ${SESSION_DIR}`); - console.log(); - startSocket(); + if (PAIR_JSON) { + emitPairEvent({ event: 'started', session: SESSION_DIR }); + } else { + console.log('📱 WhatsApp pairing mode'); + console.log(`📁 Session: ${SESSION_DIR}`); + console.log(); + } + startSocket().catch((err) => { + emitPairEvent({ event: 'error', error: err?.message || String(err) }); + if (!PAIR_JSON) { + console.error(err); + } + process.exit(1); + }); } else { app.listen(PORT, '127.0.0.1', () => { console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`); @@ -1011,6 +1102,8 @@ if (PAIR_ONLY) { console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`); } else if (WHATSAPP_MODE === 'self-chat') { console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`); + } else if (WHATSAPP_MODE === 'bot' && WHATSAPP_DM_POLICY === 'pairing') { + console.log(`🤝 WHATSAPP_DM_POLICY=pairing — unknown DMs are forwarded for gateway pairing.`); } else { console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`); console.log(` Set WHATSAPP_ALLOWED_USERS= to authorize specific users,`); diff --git a/tests/hermes_cli/test_whatsapp_onboarding.py b/tests/hermes_cli/test_whatsapp_onboarding.py new file mode 100644 index 00000000000..e07abfd9904 --- /dev/null +++ b/tests/hermes_cli/test_whatsapp_onboarding.py @@ -0,0 +1,323 @@ +import asyncio +import time + + +class _FakeProc: + def __init__(self, lines=None, returncode=0): + self.stdout = iter(lines or []) + self._returncode = returncode + self.terminated = False + self.killed = False + self.pid = 12345 + + def poll(self): + return None if not self.terminated and not self.killed else self._returncode + + def wait(self, timeout=None): + return self._returncode + + def terminate(self): + self.terminated = True + + def kill(self): + self.killed = True + + +def test_whatsapp_pairing_watcher_records_qr_and_connected(): + from hermes_cli import web_server as ws + + proc = _FakeProc([ + '{"event":"started","session":"/tmp/session"}\n', + '{"event":"qr","qr":"qr-payload"}\n', + '{"event":"connected","user":{"id":"15551234567:1@s.whatsapp.net","name":"Hermes Bot"}}\n', + ]) + record = ws._WhatsAppOnboardingSession( + proc=proc, + mode="bot", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["pairing"] = record + + ws._watch_whatsapp_pairing("pairing", proc) + + assert record.status == "connected" + assert record.qr_payload == "qr-payload" + assert record.account_id == "15551234567:1@s.whatsapp.net" + assert record.account_name == "Hermes Bot" + assert record.account_phone == "15551234567" + assert record.error is None + ws._whatsapp_onboarding_sessions.clear() + + +def test_whatsapp_pairing_payload_includes_linked_account(): + from hermes_cli import web_server as ws + + record = ws._WhatsAppOnboardingSession( + proc=None, + mode="bot", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + status="connected", + account_id="15551234567@s.whatsapp.net", + account_name="Hermes Bot", + account_phone="15551234567", + ) + + payload = ws._whatsapp_onboarding_payload("pairing", record) + + assert payload["account_id"] == "15551234567@s.whatsapp.net" + assert payload["account_name"] == "Hermes Bot" + assert payload["account_phone"] == "15551234567" + + +def test_messaging_payload_includes_safe_whatsapp_setup(monkeypatch): + from hermes_cli import web_server as ws + + entry = { + "id": "whatsapp", + "name": "WhatsApp", + "description": "WhatsApp bridge", + "docs_url": "", + "env_vars": ("WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS", "WHATSAPP_ENABLED"), + "required_env": (), + } + monkeypatch.setattr(ws, "get_running_pid", lambda: None) + monkeypatch.setattr(ws, "get_runtime_status_running_pid", lambda runtime: None) + monkeypatch.setattr( + ws, + "load_config", + lambda: { + "platforms": { + "whatsapp": { + "enabled": True, + "home_channel": { + "platform": "whatsapp", + "chat_id": "280912570925281@lid", + "name": "Home", + }, + } + } + }, + ) + + payload = ws._messaging_platform_payload( + entry, + { + "WHATSAPP_MODE": "self-chat", + "WHATSAPP_ALLOWED_USERS": "61405484224", + "WHATSAPP_ENABLED": "true", + }, + runtime=None, + scoped=True, + ) + + assert payload["whatsapp_setup"] == { + "mode": "self-chat", + "allowed_users_set": True, + "home_channel_set": True, + } + assert "61405484224" not in str(payload["whatsapp_setup"]) + + +def test_apply_whatsapp_onboarding_saves_pairing_policy(monkeypatch): + from hermes_cli import web_server as ws + + saved = {} + removed = [] + enabled = [] + + monkeypatch.setattr(ws, "save_env_value", lambda key, value: saved.setdefault(key, value)) + monkeypatch.setattr(ws, "remove_env_value", lambda key: removed.append(key)) + monkeypatch.setattr(ws, "_write_platform_enabled", lambda platform, value: enabled.append((platform, value))) + monkeypatch.setattr( + ws, + "_restart_gateway_after_whatsapp_onboarding", + lambda profile=None: {"restart_started": True, "restart_pid": 12345}, + ) + + record = ws._WhatsAppOnboardingSession( + proc=None, + mode="bot", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + status="connected", + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["pairing"] = record + + result = asyncio.run( + ws.apply_whatsapp_onboarding( + "pairing", + ws.WhatsAppOnboardingApply(mode="bot", allowed_users=""), + ) + ) + + assert result["ok"] is True + assert saved["WHATSAPP_MODE"] == "bot" + assert saved["WHATSAPP_DM_POLICY"] == "pairing" + assert saved["WHATSAPP_ENABLED"] == "true" + assert "WHATSAPP_ALLOWED_USERS" not in removed + assert enabled == [("whatsapp", True)] + assert "pairing" not in ws._whatsapp_onboarding_sessions + + +def test_apply_whatsapp_onboarding_self_chat_defaults_to_linked_account(monkeypatch): + from hermes_cli import web_server as ws + + saved = {} + removed = [] + + monkeypatch.setattr(ws, "save_env_value", lambda key, value: saved.setdefault(key, value)) + monkeypatch.setattr(ws, "remove_env_value", lambda key: removed.append(key)) + monkeypatch.setattr(ws, "_write_platform_enabled", lambda platform, value: None) + monkeypatch.setattr( + ws, + "_restart_gateway_after_whatsapp_onboarding", + lambda profile=None: {"restart_started": True, "restart_pid": 12345}, + ) + + record = ws._WhatsAppOnboardingSession( + proc=None, + mode="self-chat", + allowed_users="", + session_path="/tmp/session", + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + status="connected", + account_id="15551234567:1@s.whatsapp.net", + account_phone="15551234567", + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["pairing"] = record + + result = asyncio.run( + ws.apply_whatsapp_onboarding( + "pairing", + ws.WhatsAppOnboardingApply(mode="self-chat", allowed_users=""), + ) + ) + + assert result["ok"] is True + assert saved["WHATSAPP_MODE"] == "self-chat" + assert saved["WHATSAPP_ALLOWED_USERS"] == "15551234567" + assert "WHATSAPP_ALLOWED_USERS" not in removed + assert "pairing" not in ws._whatsapp_onboarding_sessions + + +def test_start_whatsapp_onboarding_existing_creds_returns_linked_account(monkeypatch, tmp_path): + from hermes_cli import web_server as ws + + session_dir = tmp_path / "session" + session_dir.mkdir() + (session_dir / "creds.json").write_text( + '{"me":{"id":"15551234567:1@s.whatsapp.net","name":"Hermes Bot"}}', + encoding="utf-8", + ) + + old_proc = _FakeProc(returncode=1) + old_record = ws._WhatsAppOnboardingSession( + proc=old_proc, + mode="bot", + allowed_users="", + session_path=str(session_dir), + expires_at="2099-01-01T00:00:00Z", + expires_at_ts=time.time() + 600, + ) + ws._whatsapp_onboarding_sessions.clear() + ws._whatsapp_onboarding_sessions["old"] = old_record + monkeypatch.setattr(ws, "_whatsapp_session_path", lambda: session_dir) + monkeypatch.setattr(ws.secrets, "token_urlsafe", lambda size: "existing-creds") + + result = asyncio.run( + ws.start_whatsapp_onboarding( + ws.WhatsAppOnboardingStart(mode="self-chat", allowed_users="") + ) + ) + + assert result["pairing_id"] == "existing-creds" + assert result["status"] == "connected" + assert result["qr_payload"] is None + assert result["account_id"] == "15551234567:1@s.whatsapp.net" + assert result["account_name"] == "Hermes Bot" + assert result["account_phone"] == "15551234567" + assert old_record.status == "cancelled" + assert old_proc.terminated is True + assert ws._whatsapp_onboarding_sessions["existing-creds"].account_phone == "15551234567" + ws._whatsapp_onboarding_sessions.clear() + + +def test_start_whatsapp_onboarding_returns_before_bridge_spawn(monkeypatch, tmp_path): + from hermes_cli import web_server as ws + + captured = {} + + class FakeThread: + def __init__(self, *, target, args, daemon): + captured["target"] = target + captured["args"] = args + captured["daemon"] = daemon + + def start(self): + captured["started"] = True + + ws._whatsapp_onboarding_sessions.clear() + monkeypatch.setattr(ws, "_whatsapp_session_path", lambda: tmp_path / "session") + monkeypatch.setattr(ws.secrets, "token_urlsafe", lambda size: "pairing-start") + monkeypatch.setattr(ws.threading, "Thread", FakeThread) + + result = asyncio.run( + ws.start_whatsapp_onboarding( + ws.WhatsAppOnboardingStart(mode="bot", allowed_users="") + ) + ) + + assert result["pairing_id"] == "pairing-start" + assert result["status"] == "starting" + assert result["qr_payload"] is None + assert captured["target"] is ws._run_whatsapp_pairing + assert captured["args"] == ("pairing-start", tmp_path / "session", "bot") + assert captured["daemon"] is True + assert captured["started"] is True + assert ws._whatsapp_onboarding_sessions["pairing-start"].proc is None + ws._whatsapp_onboarding_sessions.clear() + + +def test_spawn_whatsapp_pairing_process_uses_json_mode(monkeypatch, tmp_path): + from gateway.platforms import whatsapp_common + from hermes_cli import web_server as ws + import hermes_constants + + bridge_dir = tmp_path / "bridge" + bridge_dir.mkdir() + (bridge_dir / "bridge.js").write_text("// bridge", encoding="utf-8") + session_dir = tmp_path / "session" + captured = {} + + monkeypatch.setattr(whatsapp_common, "resolve_whatsapp_bridge_dir", lambda: bridge_dir) + monkeypatch.setattr(hermes_constants, "find_node_executable", lambda command: "/usr/bin/node") + monkeypatch.setattr(hermes_constants, "with_hermes_node_path", lambda env=None: {}) + monkeypatch.setattr(ws, "_ensure_whatsapp_bridge_dependencies", lambda bridge_dir: None) + + def fake_popen(args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return _FakeProc() + + monkeypatch.setattr(ws.subprocess, "Popen", fake_popen) + + proc = ws._spawn_whatsapp_pairing_process(session_dir, "bot") + + assert isinstance(proc, _FakeProc) + assert "--pair-only" in captured["args"] + assert "--pair-json" in captured["args"] + assert str(session_dir) in captured["args"] + assert captured["kwargs"]["env"]["WHATSAPP_MODE"] == "bot" + assert captured["kwargs"]["env"]["WHATSAPP_DM_POLICY"] == "pairing" diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8aa443828e4..e6320812982 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -75,6 +75,7 @@ const PROFILE_SCOPED_PREFIXES = [ "/api/mcp", "/api/messaging/platforms", "/api/messaging/telegram/onboarding", + "/api/messaging/whatsapp/onboarding", "/api/model/info", "/api/model/set", "/api/model/auxiliary", @@ -887,6 +888,39 @@ export const api = { `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, { method: "DELETE" }, ), + startWhatsAppOnboarding: (body: { + mode?: "bot" | "self-chat"; + allowed_users?: string; + }) => + fetchJSON( + "/api/messaging/whatsapp/onboarding/start", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + getWhatsAppOnboardingStatus: (pairingId: string) => + fetchJSON( + `/api/messaging/whatsapp/onboarding/${encodeURIComponent(pairingId)}`, + ), + applyWhatsAppOnboarding: ( + pairingId: string, + body: { mode?: "bot" | "self-chat"; allowed_users?: string; profile?: string }, + ) => + fetchJSON( + `/api/messaging/whatsapp/onboarding/${encodeURIComponent(pairingId)}/apply`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + cancelWhatsAppOnboarding: (pairingId: string) => + fetchJSON<{ ok: boolean }>( + `/api/messaging/whatsapp/onboarding/${encodeURIComponent(pairingId)}`, + { method: "DELETE" }, + ), // Gateway / update actions restartGateway: () => @@ -1466,6 +1500,11 @@ export interface MessagingPlatform { error_message: string | null; updated_at: string | null; home_channel: { platform: string; chat_id: string; name: string; thread_id?: string } | null; + whatsapp_setup?: { + mode?: string; + allowed_users_set?: boolean; + home_channel_set?: boolean; + } | null; env_vars: MessagingPlatformEnvVar[]; } @@ -1849,6 +1888,38 @@ export interface TelegramOnboardingApplyResponse { restart_error?: string; } +export interface WhatsAppOnboardingStartResponse { + pairing_id: string; + status: + | "starting" + | "installing" + | "waiting" + | "connected" + | "error" + | "expired" + | "cancelled"; + qr_payload?: string | null; + expires_at: string; + mode: "bot" | "self-chat"; + allowed_users: string; + account_id?: string | null; + account_name?: string | null; + account_phone?: string | null; + error?: string | null; +} + +export type WhatsAppOnboardingStatusResponse = WhatsAppOnboardingStartResponse; + +export interface WhatsAppOnboardingApplyResponse { + ok: boolean; + platform: "whatsapp"; + needs_restart: boolean; + restart_started?: boolean; + restart_action?: string; + restart_pid?: number | null; + restart_error?: string; +} + export interface SessionMessage { role: "user" | "assistant" | "system" | "tool"; content: string | null; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index baee6b87666..061468e15ca 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -30,6 +30,7 @@ import type { MessagingPlatformEnvVar, MessagingPlatformUpdate, TelegramOnboardingStartResponse, + WhatsAppOnboardingStartResponse, } from "@/lib/api"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { usePageHeader } from "@/contexts/usePageHeader"; @@ -102,6 +103,15 @@ function isTerminalTelegramOnboardingError(error: unknown): boolean { return /\b410\b/.test(message) && /\b(expired|claimed|gone)\b/i.test(message); } +function isTerminalWhatsAppOnboardingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /\b410\b/.test(message) && /\b(expired|gone)\b/i.test(message); +} + +function normalizeWhatsAppMode(mode: unknown): "bot" | "self-chat" | null { + return mode === "bot" || mode === "self-chat" ? mode : null; +} + export default function ChannelsPage() { const [platforms, setPlatforms] = useState([]); const [envPath, setEnvPath] = useState("~/.hermes/.env"); @@ -533,6 +543,15 @@ export default function ChannelsPage() { showToast={showToast} /> )} + {platform.id === "whatsapp" && ( + setRestartNeeded(true)} + platform={platform} + setRestartNeeded={setRestartNeeded} + showToast={showToast} + /> + )} ); @@ -542,6 +561,413 @@ export default function ChannelsPage() { ); } +function WhatsAppOnboardingPanel({ + onChanged, + onRestartNeeded, + platform, + setRestartNeeded, + showToast, +}: { + onChanged: () => Promise; + onRestartNeeded: () => void; + platform: MessagingPlatform; + setRestartNeeded: (needed: boolean) => void; + showToast: (message: string, type: "success" | "error") => void; +}) { + const configuredMode = useMemo( + () => normalizeWhatsAppMode(platform.whatsapp_setup?.mode), + [platform.whatsapp_setup?.mode], + ); + const [setup, setSetup] = useState( + null, + ); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [phase, setPhase] = useState< + "idle" | "starting" | "waiting" | "connected" | "applying" + >("idle"); + const [mode, setMode] = useState<"bot" | "self-chat">( + configuredMode ?? "bot", + ); + const [allowedUsers, setAllowedUsers] = useState(""); + const [error, setError] = useState(""); + const [tick, setTick] = useState(0); + + useEffect(() => { + if (!setup && phase === "idle" && configuredMode) { + setMode(configuredMode); + } + }, [configuredMode, phase, setup]); + + const updateQr = useCallback(async (payload?: string | null) => { + if (!payload) return; + const dataUrl = await QRCode.toDataURL(payload, { + errorCorrectionLevel: "M", + margin: 3, + width: 240, + }); + setQrDataUrl(dataUrl); + }, []); + + useEffect(() => { + if (!setup || phase !== "waiting") return; + let cancelled = false; + let timeout: ReturnType | null = null; + + const poll = async () => { + try { + const status = await api.getWhatsAppOnboardingStatus(setup.pairing_id); + if (cancelled) return; + setSetup(status); + if (status.qr_payload && status.qr_payload !== setup.qr_payload) { + await updateQr(status.qr_payload); + } + if (cancelled) return; + if (status.status === "connected") { + setPhase("connected"); + setError(""); + return; + } + if (status.status === "error") { + setError(status.error || "WhatsApp setup failed."); + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + return; + } + setError(""); + timeout = setTimeout(poll, 1500); + } catch (pollError) { + if (cancelled) return; + const expiresAt = Date.parse(setup.expires_at); + const expired = + Number.isFinite(expiresAt) && Date.now() >= expiresAt; + if (isTerminalWhatsAppOnboardingError(pollError) || expired) { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setError("WhatsApp QR setup expired. Start a new QR setup to try again."); + return; + } + setError(`Still waiting for WhatsApp. Retrying after: ${pollError}`); + timeout = setTimeout(poll, 2000); + } + }; + + timeout = setTimeout(poll, 1000); + return () => { + cancelled = true; + if (timeout) clearTimeout(timeout); + }; + }, [phase, setup, updateQr]); + + useEffect(() => { + if (!setup) return; + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, [setup]); + + const resetSetup = () => { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setError(""); + }; + + const start = async () => { + setPhase("starting"); + setError(""); + setQrDataUrl(""); + try { + const res = await api.startWhatsAppOnboarding({ + mode, + allowed_users: allowedUsers, + }); + setSetup(res); + if (res.qr_payload) { + await updateQr(res.qr_payload); + } + if (res.status === "error") { + setError(res.error || "WhatsApp setup failed."); + setSetup(null); + setPhase("idle"); + } else { + setPhase(res.status === "connected" ? "connected" : "waiting"); + } + } catch (startError) { + setPhase("idle"); + setError(String(startError)); + } + }; + + const cancel = async () => { + if (setup) { + try { + await api.cancelWhatsAppOnboarding(setup.pairing_id); + } catch { + /* local cleanup still wins */ + } + } + resetSetup(); + }; + + const watchRestartOutcome = async () => { + for (let i = 0; i < 20; i++) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + try { + const st = await api.getActionStatus("gateway-restart", 5); + if (st.running) continue; + if (st.exit_code !== 0 && st.exit_code !== null) { + onRestartNeeded(); + showToast( + `Gateway restart failed (exit ${st.exit_code}) — restart manually`, + "error", + ); + } + return; + } catch { + // transient fetch error; keep polling + } + } + }; + + const apply = async () => { + if (!setup) return; + setPhase("applying"); + setError(""); + try { + const result = await api.applyWhatsAppOnboarding(setup.pairing_id, { + mode, + allowed_users: allowedUsers, + }); + resetSetup(); + if (result.restart_started) { + showToast("WhatsApp saved; gateway restarting…", "success"); + setRestartNeeded(false); + setTimeout(() => void onChanged(), 4000); + void watchRestartOutcome(); + } else { + onRestartNeeded(); + const detail = result.restart_error ? `: ${result.restart_error}` : ""; + showToast(`WhatsApp saved; gateway restart failed${detail}`, "error"); + } + await onChanged(); + } catch (applyError) { + setPhase("connected"); + setError(String(applyError)); + } + }; + + const expiresIn = useMemo( + () => (setup ? formatExpiry(setup.expires_at) : ""), + // tick keeps the memo fresh without recalculating on every render branch. + // eslint-disable-next-line react-hooks/exhaustive-deps + [setup, tick], + ); + const setupStatusLabel = + setup?.status === "installing" + ? "preparing" + : setup?.status === "starting" + ? "starting" + : "waiting"; + const setupHelp = + phase === "connected" || phase === "applying" + ? "WhatsApp is linked but Hermes is not listening yet. Save and restart the gateway to finish setup." + : setup?.status === "installing" + ? "Preparing the WhatsApp bridge. The QR code will appear here when it is ready." + : setup?.status === "starting" + ? "Starting the WhatsApp pairing bridge. The QR code will appear here when it is ready." + : "Open WhatsApp on your phone, then go to Linked Devices and scan from there. This QR is not a browser URL."; + const linkedAccountLabel = setup?.account_phone + ? `+${setup.account_phone}` + : setup?.account_name || setup?.account_id || ""; + const linkedAccountDetail = + setup?.account_phone || setup?.account_id + ? "This is the WhatsApp account Hermes is now logged into." + : "Hermes is logged into the WhatsApp account that scanned the QR code."; + const linkedAccountChatUrl = setup?.account_phone + ? `https://wa.me/${setup.account_phone}` + : ""; + const messageInstruction = + mode === "self-chat" + ? "After the restart, open Message Yourself on the linked account and send Hermes a message." + : "After the restart, start a chat from another WhatsApp account with the linked account and send Hermes a message."; + const hasSavedAllowedUsers = Boolean(platform.whatsapp_setup?.allowed_users_set); + const pairingInstruction = + mode === "self-chat" && !allowedUsers.trim() + ? hasSavedAllowedUsers + ? "Hermes will keep the saved WhatsApp allowlist." + : "Self-chat mode will allow the linked account automatically when you save." + : !allowedUsers.trim() && hasSavedAllowedUsers + ? "Hermes will keep the saved WhatsApp allowlist." + : "If no allowed numbers were entered, Hermes replies with a pairing code. Approve it from the dashboard Pairing page."; + + return ( +
+
+
+ + {platform.configured && ( + + Existing WhatsApp settings are configured. + + )} +
+ +
+
+ + Mode + +
+ + +
+
+
+ + setAllowedUsers(event.target.value)} + disabled={phase === "waiting" || phase === "applying"} + placeholder="15551234567,15557654321" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + {setup && ( +
+
+
+ {phase === "connected" || phase === "applying" ? ( + Connected + ) : ( + {setupStatusLabel} + )} + + {expiresIn} + +
+ +
{setupHelp}
+ + {phase === "waiting" && ( +
+ After saving, unknown DMs use Hermes pairing codes unless their + number is already allowed. +
+ )} + + {(phase === "connected" || phase === "applying") && ( +
+
+
+ {linkedAccountLabel + ? `Linked as ${linkedAccountLabel}` + : "WhatsApp device linked"} +
+
{linkedAccountDetail}
+
    +
  1. Save and restart the gateway.
  2. +
  3. {messageInstruction}
  4. +
  5. {pairingInstruction}
  6. +
+ {linkedAccountChatUrl && ( + + Open chat link + + + )} +
+
+ + +
+
+ )} +
+ +
+ {qrDataUrl ? ( + WhatsApp setup QR code + ) : phase === "connected" || phase === "applying" ? ( +
+ Linked +
+ {linkedAccountLabel || "Existing WhatsApp session found"} +
+
+ ) : ( +
+ +
+ Waiting for WhatsApp to provide a QR code… +
+
+ )} + {phase === "waiting" && ( + + Scan with WhatsApp Linked Devices, not the camera app. + + )} + +
+
+ )} +
+
+ ); +} + function TelegramOnboardingPanel({ onChanged, onRestartNeeded,