Add WhatsApp dashboard pairing flow

This commit is contained in:
Shannon Sands 2026-07-02 12:28:26 +10:00 committed by Teknium
parent 6015ee5d2a
commit 4f620a0bbc
6 changed files with 1485 additions and 41 deletions

View file

@ -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+$")

View file

@ -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

View file

@ -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=<phone> to authorize specific users,`);

View file

@ -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"

View file

@ -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<WhatsAppOnboardingStartResponse>(
"/api/messaging/whatsapp/onboarding/start",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
getWhatsAppOnboardingStatus: (pairingId: string) =>
fetchJSON<WhatsAppOnboardingStatusResponse>(
`/api/messaging/whatsapp/onboarding/${encodeURIComponent(pairingId)}`,
),
applyWhatsAppOnboarding: (
pairingId: string,
body: { mode?: "bot" | "self-chat"; allowed_users?: string; profile?: string },
) =>
fetchJSON<WhatsAppOnboardingApplyResponse>(
`/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;

View file

@ -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<MessagingPlatform[]>([]);
const [envPath, setEnvPath] = useState("~/.hermes/.env");
@ -533,6 +543,15 @@ export default function ChannelsPage() {
showToast={showToast}
/>
)}
{platform.id === "whatsapp" && (
<WhatsAppOnboardingPanel
onChanged={load}
onRestartNeeded={() => setRestartNeeded(true)}
platform={platform}
setRestartNeeded={setRestartNeeded}
showToast={showToast}
/>
)}
</CardContent>
</Card>
);
@ -542,6 +561,413 @@ export default function ChannelsPage() {
);
}
function WhatsAppOnboardingPanel({
onChanged,
onRestartNeeded,
platform,
setRestartNeeded,
showToast,
}: {
onChanged: () => Promise<void>;
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<WhatsAppOnboardingStartResponse | null>(
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<typeof setTimeout> | 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 (
<div className="rounded-sm border border-border bg-background/35 p-4">
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
className="uppercase"
onClick={() => void start()}
disabled={phase === "starting" || phase === "waiting" || phase === "applying"}
prefix={phase === "starting" ? <Spinner /> : <QrCode className="h-4 w-4" />}
>
{phase === "starting" ? "Starting…" : "Pair with QR"}
</Button>
{platform.configured && (
<span className="text-xs text-muted-foreground">
Existing WhatsApp settings are configured.
</span>
)}
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="grid gap-1.5">
<span className="text-xs uppercase tracking-[0.12em] text-muted-foreground">
Mode
</span>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
outlined={mode !== "bot"}
onClick={() => setMode("bot")}
disabled={phase === "waiting" || phase === "applying"}
>
Bot
</Button>
<Button
size="sm"
outlined={mode !== "self-chat"}
onClick={() => setMode("self-chat")}
disabled={phase === "waiting" || phase === "applying"}
>
Self-chat
</Button>
</div>
</div>
<div className="grid min-w-0 flex-1 gap-1.5">
<Label htmlFor="whatsapp-allowed-users">Allowed WhatsApp numbers</Label>
<Input
id="whatsapp-allowed-users"
value={allowedUsers}
onChange={(event) => setAllowedUsers(event.target.value)}
disabled={phase === "waiting" || phase === "applying"}
placeholder="15551234567,15557654321"
/>
</div>
</div>
{error && (
<div className="border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{setup && (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]">
<div className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
{phase === "connected" || phase === "applying" ? (
<Badge tone="success">Connected</Badge>
) : (
<Badge tone="warning">{setupStatusLabel}</Badge>
)}
<Badge tone={expiresIn === "expired" ? "destructive" : "outline"}>
{expiresIn}
</Badge>
</div>
<div className="text-sm text-muted-foreground">{setupHelp}</div>
{phase === "waiting" && (
<div className="text-xs text-muted-foreground">
After saving, unknown DMs use Hermes pairing codes unless their
number is already allowed.
</div>
)}
{(phase === "connected" || phase === "applying") && (
<div className="grid gap-3">
<div className="border border-border bg-background/45 p-3 text-sm">
<div className="font-medium">
{linkedAccountLabel
? `Linked as ${linkedAccountLabel}`
: "WhatsApp device linked"}
</div>
<div className="mt-1 text-muted-foreground">{linkedAccountDetail}</div>
<ol className="mt-3 list-decimal space-y-1 pl-5 text-muted-foreground">
<li>Save and restart the gateway.</li>
<li>{messageInstruction}</li>
<li>{pairingInstruction}</li>
</ol>
{linkedAccountChatUrl && (
<a
className="mt-3 inline-flex items-center gap-1 text-sm text-primary underline-offset-4 hover:underline"
href={linkedAccountChatUrl}
target="_blank"
rel="noreferrer"
>
Open chat link
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
className="uppercase"
onClick={() => void apply()}
disabled={phase === "applying"}
prefix={phase === "applying" ? <Spinner /> : <Save className="h-4 w-4" />}
>
{phase === "applying" ? "Saving…" : "Save and restart"}
</Button>
<Button size="sm" ghost onClick={() => void cancel()}>
Cancel
</Button>
</div>
</div>
)}
</div>
<div className="flex flex-col items-center justify-center gap-3">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt="WhatsApp setup QR code"
className="h-60 w-60 bg-white p-2"
/>
) : phase === "connected" || phase === "applying" ? (
<div className="flex h-60 w-60 flex-col items-center justify-center gap-2 border border-border bg-background/50 p-4 text-center">
<Badge tone="success">Linked</Badge>
<div className="text-sm text-muted-foreground">
{linkedAccountLabel || "Existing WhatsApp session found"}
</div>
</div>
) : (
<div className="flex h-60 w-60 flex-col items-center justify-center gap-3 border border-border bg-background/50 p-4 text-center">
<Spinner className="text-2xl" />
<div className="text-xs text-muted-foreground">
Waiting for WhatsApp to provide a QR code
</div>
</div>
)}
{phase === "waiting" && (
<span className="text-center text-xs text-muted-foreground">
Scan with WhatsApp Linked Devices, not the camera app.
</span>
)}
<Button size="sm" ghost onClick={() => void cancel()}>
Cancel
</Button>
</div>
</div>
)}
</div>
</div>
);
}
function TelegramOnboardingPanel({
onChanged,
onRestartNeeded,