mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Add WhatsApp dashboard pairing flow
This commit is contained in:
parent
6015ee5d2a
commit
4f620a0bbc
6 changed files with 1485 additions and 41 deletions
|
|
@ -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+$")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,`);
|
||||
|
|
|
|||
323
tests/hermes_cli/test_whatsapp_onboarding.py
Normal file
323
tests/hermes_cli/test_whatsapp_onboarding.py
Normal 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"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue