Files
hermes-agent/hermes_cli/web_routers/messaging.py
T

1381 lines
51 KiB
Python

"""Messaging dashboard routes: WhatsApp/Telegram onboarding and per-platform enable/config/test.
Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on
``web_server`` stay there and are imported lazily at call time (cycle-safe).
"""
import logging
import re
import subprocess
import threading
import asyncio
import json
import secrets
import time
import urllib.parse
import os
from datetime import datetime, timezone
from fastapi import APIRouter
from hermes_cli.web_deps import late
from fastapi import HTTPException
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli.config import get_env_path
from hermes_cli.web_models import MessagingPlatformUpdate, TelegramOnboardingStart, TelegramOnboardingApply, WhatsAppOnboardingStart, WhatsAppOnboardingApply
from pathlib import Path
from typing import Any, Optional
from hermes_cli.config import redact_key
from gateway.status import resolve_gateway_liveness
from hermes_cli.config import OPTIONAL_ENV_VARS
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
# web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative.
_config_profile_scope = late("_config_profile_scope")
_messaging_platform_catalog = late("_messaging_platform_catalog")
_profile_scope = late("_profile_scope")
_resolve_profile_dir = late("_resolve_profile_dir")
_restart_gateway_after_whatsapp_onboarding = late("_restart_gateway_after_whatsapp_onboarding")
_restart_gateway_after = late("_restart_gateway_after")
_telegram_onboarding_error_message = late("_telegram_onboarding_error_message")
_telegram_onboarding_request_sync = late("_telegram_onboarding_request_sync")
_whatsapp_onboarding_payload = late("_whatsapp_onboarding_payload")
_whatsapp_session_path = late("_whatsapp_session_path")
_write_platform_enabled = late("_write_platform_enabled")
load_env = late("load_env")
read_runtime_status = late("read_runtime_status")
remove_env_value = late("remove_env_value")
save_env_value = late("save_env_value")
_gateway_subcommand = late("_gateway_subcommand")
_probe_gateway_health = late("_probe_gateway_health")
# Display labels for env vars not in OPTIONAL_ENV_VARS (HOME_CHANNEL_*, bridge
# toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS
# falls back here so the UI can still render a friendly label.
_MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = {
"SIGNAL_HTTP_URL": {
"description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080",
"prompt": "Signal bridge URL",
"url": "https://github.com/bbernhard/signal-cli-rest-api",
},
"SIGNAL_ACCOUNT": {
"description": "Signal account phone number registered with the bridge",
"prompt": "Signal account",
},
"SIGNAL_ALLOWED_USERS": {
"description": "Comma-separated Signal users allowed to use the bot",
"prompt": "Allowed Signal users",
},
"WHATSAPP_ENABLED": {
"description": "Enable the WhatsApp gateway adapter",
"prompt": "Enable WhatsApp",
"advanced": True,
},
"WHATSAPP_MODE": {
"description": "WhatsApp bridge mode",
"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",
},
"HASS_URL": {
"description": "Home Assistant base URL, e.g. https://homeassistant.local:8123",
"prompt": "Home Assistant URL",
},
"HASS_TOKEN": {
"description": "Long-lived access token from Home Assistant (Profile → Security)",
"prompt": "Home Assistant access token",
"password": True,
},
"EMAIL_ADDRESS": {
"description": "Email address to send and receive from",
"prompt": "Email address",
},
"EMAIL_PASSWORD": {
"description": "Email account password or app password",
"prompt": "Email password",
"password": True,
},
"EMAIL_IMAP_HOST": {
"description": "IMAP server host (e.g. imap.gmail.com)",
"prompt": "IMAP host",
},
"EMAIL_SMTP_HOST": {
"description": "SMTP server host (e.g. smtp.gmail.com)",
"prompt": "SMTP host",
},
"TWILIO_ACCOUNT_SID": {
"description": "Twilio Account SID",
"prompt": "Twilio Account SID",
"url": "https://www.twilio.com/console",
},
"TWILIO_AUTH_TOKEN": {
"description": "Twilio Auth Token",
"prompt": "Twilio Auth Token",
"password": True,
},
"WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"},
"WECOM_SECRET": {
"description": "WeCom group bot secret",
"prompt": "WeCom Secret",
"password": True,
},
"WECOM_CALLBACK_CORP_ID": {
"description": "WeCom corp ID",
"prompt": "WeCom Corp ID",
},
"WECOM_CALLBACK_CORP_SECRET": {
"description": "WeCom app corp secret",
"prompt": "WeCom Corp Secret",
"password": True,
},
"WECOM_CALLBACK_AGENT_ID": {
"description": "WeCom app agent ID",
"prompt": "WeCom Agent ID",
},
"WECOM_CALLBACK_TOKEN": {
"description": "WeCom callback verification token",
"prompt": "WeCom Token",
},
"WECOM_CALLBACK_ENCODING_AES_KEY": {
"description": "WeCom callback AES encoding key",
"prompt": "WeCom AES Key",
"password": True,
},
"WEIXIN_ACCOUNT_ID": {
"description": "iLink Bot account ID obtained through QR login in hermes gateway setup",
"prompt": "iLink Bot account ID",
},
"WEIXIN_TOKEN": {
"description": "iLink Bot token obtained through QR login in hermes gateway setup",
"prompt": "iLink Bot token",
"password": True,
},
"WEIXIN_BASE_URL": {
"description": "iLink API base URL saved by QR login (default: https://ilinkai.weixin.qq.com)",
"prompt": "iLink API base URL",
},
"FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"},
"FEISHU_APP_SECRET": {
"description": "Feishu / Lark app secret",
"prompt": "App secret",
"password": True,
},
"FEISHU_ENCRYPT_KEY": {
"description": "Feishu / Lark encrypt key",
"prompt": "Encrypt key",
"password": True,
},
"FEISHU_VERIFICATION_TOKEN": {
"description": "Feishu / Lark verification token",
"prompt": "Verification token",
"password": True,
},
"DINGTALK_CLIENT_ID": {
"description": "DingTalk client ID (App key)",
"prompt": "Client ID",
},
"DINGTALK_CLIENT_SECRET": {
"description": "DingTalk client secret (App secret)",
"prompt": "Client secret",
"password": True,
},
}
# Kept in sync with the corresponding frontend validation in ChannelsPage.tsx.
_TELEGRAM_BOT_TOKEN_RE = re.compile(r"\d+:[A-Za-z0-9_-]{30,}")
_TELEGRAM_USER_ID_RE = re.compile(r"\d+")
_SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}")
def _messaging_env_info(key: str) -> dict[str, Any]:
info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {}
return {
"description": info.get("description", ""),
"prompt": info.get("prompt", key),
"help": info.get("help", ""),
"url": info.get("url"),
"is_password": info.get("password", False),
"advanced": info.get("advanced", False),
}
def _gateway_platform_config(platform_id: str):
from gateway.config import Platform, load_gateway_config
config = load_gateway_config()
platform = Platform(platform_id)
platform_config = config.platforms.get(platform)
return config, platform, platform_config
def _gateway_display_command(profile: Optional[str], verb: str) -> str:
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None:
"""Reject platform credentials that are clearly in the wrong field."""
if not value:
return
if platform_id == "telegram":
if key == "TELEGRAM_BOT_TOKEN" and not _TELEGRAM_BOT_TOKEN_RE.fullmatch(value):
raise HTTPException(
status_code=400,
detail="Telegram bot token must be the complete token from @BotFather, such as 123456789:ABC…",
)
if key == "TELEGRAM_ALLOWED_USERS":
user_ids = [part.strip() for part in value.split(",") if part.strip()]
if any(not _TELEGRAM_USER_ID_RE.fullmatch(user_id) for user_id in user_ids):
raise HTTPException(
status_code=400,
detail="Telegram allowed users must be comma-separated numeric user IDs.",
)
return
if platform_id != "slack":
return
if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"):
raise HTTPException(
status_code=400,
detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.",
)
if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"):
raise HTTPException(
status_code=400,
detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.",
)
if key == "SLACK_ALLOWED_USERS":
# Mirror the gateway's parse (gateway/platforms/slack.py): split on comma,
# strip, and drop empty entries so a trailing/interior comma isn't rejected
# here when the runtime would accept it. "*" is the allow-all wildcard.
user_ids = [part.strip() for part in value.split(",") if part.strip()]
invalid = [
user_id
for user_id in user_ids
if user_id != "*" and not _SLACK_MEMBER_ID_RE.fullmatch(user_id)
]
if invalid:
raise HTTPException(
status_code=400,
detail="Slack allowed user IDs must be comma-separated member IDs like U01ABC2DEF3.",
)
def _catalog_lookup(platform_id: str) -> dict[str, Any] | None:
for entry in _messaging_platform_catalog():
if entry["id"] == platform_id:
return entry
return None
def _messaging_platform_payload(
entry: dict[str, Any],
env_on_disk: dict[str, str],
runtime: dict | None,
scoped: bool = False,
profile_home: Optional[Path] = None,
) -> dict[str, Any]:
from hermes_cli.web_server import (
_GATEWAY_HEALTH_URL,
get_running_pid_cached,
get_runtime_status_running_pid,
load_config,
)
platform_id = entry["id"]
runtime_platforms = runtime.get("platforms") if runtime else {}
runtime_platform = (
runtime_platforms.get(platform_id, {})
if isinstance(runtime_platforms, dict)
else {}
)
# Same shared ladder /api/status uses. Before this was unified, the two
# endpoints disagreed on the same page load — the sidebar strip read
# "running" (it probed GATEWAY_HEALTH_URL and scoped to the requested
# profile) while the Channels page rendered "The gateway is not running"
# (it did neither). Cross-container, profile-scoped, and
# launch-service-managed deployments each hit that split.
#
# profile_home is passed when the request was scoped to a named profile:
# gateway/status readers resolve process-level paths and do NOT follow the
# HERMES_HOME contextvar override (#56986 / #69143), so the profile's
# directory has to be handed over explicitly or messaging silently reports
# another profile's gateway (#71211).
liveness = resolve_gateway_liveness(
profile_dir=profile_home,
runtime=runtime,
health_probe=(
_probe_gateway_health if _GATEWAY_HEALTH_URL else None
),
pid_probe=get_running_pid_cached,
runtime_reader=read_runtime_status,
runtime_pid_probe=get_runtime_status_running_pid,
)
gateway_running = liveness.running
env_vars = []
for key in entry["env_vars"]:
# When profile-scoped, judge only the profile's own .env — the
# dashboard process's os.environ carries the ROOT install's .env
# (loaded at startup) and would falsely report the root credentials
# as the profile's.
value = env_on_disk.get(key) or ("" if scoped else os.getenv(key, ""))
env_vars.append(
{
"key": key,
"required": key in entry["required_env"],
"is_set": bool(value),
"redacted_value": redact_key(value) if value else None,
**_messaging_env_info(key),
}
)
if scoped:
# Profile-scoped view: derive enablement/configuration from the
# profile's config.yaml + .env only. load_gateway_config()'s
# env-override layer reads os.environ and would leak the root
# install's tokens into the profile's reported state.
try:
cfg = load_config()
platforms_cfg = cfg.get("platforms") or {}
plat_cfg = platforms_cfg.get(platform_id)
if not isinstance(plat_cfg, dict):
plat_cfg = {}
enabled = bool(plat_cfg.get("enabled"))
hc = plat_cfg.get("home_channel")
home_channel = hc if isinstance(hc, dict) else None
except Exception:
enabled = False
home_channel = None
configured = all(env_on_disk.get(key) for key in entry["required_env"])
else:
try:
gateway_config, platform, platform_config = _gateway_platform_config(
platform_id
)
enabled = bool(platform_config and platform_config.enabled)
configured = bool(
platform_config
and gateway_config._is_platform_connected(platform, platform_config)
)
home_channel = (
platform_config.home_channel.to_dict()
if platform_config and platform_config.home_channel
else None
)
except Exception:
enabled = False
configured = all(
env_on_disk.get(key) or os.getenv(key, "")
for key in entry["required_env"]
)
home_channel = None
state = (
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
)
runtime_gateway_state = runtime.get("gateway_state") if isinstance(runtime, dict) else None
runtime_gateway_error = runtime.get("exit_reason") if isinstance(runtime, dict) else None
if not enabled:
state = "disabled"
elif not configured:
state = "not_configured"
elif gateway_running and not state:
state = "pending_restart"
elif (
not gateway_running
and not state
and runtime_gateway_state == "startup_failed"
):
state = "startup_failed"
elif not gateway_running and not state:
state = "gateway_stopped"
error_code = (
runtime_platform.get("error_code")
if isinstance(runtime_platform, dict)
else None
)
error_message = (
runtime_platform.get("error_message")
if isinstance(runtime_platform, dict)
else None
)
if state == "startup_failed":
error_code = error_code or "startup_failed"
error_message = error_message or runtime_gateway_error
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"],
"docs_url": entry["docs_url"],
"enabled": enabled,
"configured": configured,
"gateway_running": gateway_running,
"state": state,
"error_code": error_code,
"error_message": error_message,
"updated_at": (
runtime_platform.get("updated_at")
if isinstance(runtime_platform, dict)
else None
),
"home_channel": home_channel,
"env_vars": env_vars,
}
if whatsapp_setup is not None:
payload["whatsapp_setup"] = whatsapp_setup
return payload
_WHATSAPP_ONBOARDING_TTL_SECONDS = 600
_WHATSAPP_ONBOARDING_TERMINAL_STATUSES = {"connected", "error", "expired", "cancelled"}
_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_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,
# npm output is UTF-8; guard the Windows ANSI-code-page default
# against undefined bytes crashing the reader thread (#52649).
encoding="utf-8",
errors="replace",
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:
from hermes_cli.web_server import _whatsapp_onboarding_sessions
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:
from hermes_cli.web_server import _whatsapp_onboarding_sessions
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:
from hermes_cli.web_server import _whatsapp_onboarding_sessions
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:
from hermes_cli.web_server import _whatsapp_onboarding_sessions
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)
@router.post("/api/messaging/whatsapp/onboarding/start")
async def start_whatsapp_onboarding(body: WhatsAppOnboardingStart):
from hermes_cli.web_server import _WhatsAppOnboardingSession, _whatsapp_onboarding_sessions
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)
@router.get("/api/messaging/whatsapp/onboarding/{pairing_id}")
async def get_whatsapp_onboarding_status(pairing_id: str):
from hermes_cli.web_server import _whatsapp_onboarding_sessions
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)
@router.post("/api/messaging/whatsapp/onboarding/{pairing_id}/apply")
async def apply_whatsapp_onboarding(
pairing_id: str, body: WhatsAppOnboardingApply, profile: Optional[str] = None
):
from hermes_cli.web_server import _whatsapp_onboarding_sessions
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,
}
@router.delete("/api/messaging/whatsapp/onboarding/{pairing_id}")
async def cancel_whatsapp_onboarding(pairing_id: str):
from hermes_cli.web_server import _whatsapp_onboarding_sessions
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}
def _parse_expiry_ts(value: str) -> float:
try:
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
except Exception:
return time.time() + 600
def _prune_telegram_onboarding_pairings() -> None:
from hermes_cli.web_server import _telegram_onboarding_pairings
now = time.time()
expired = [
pairing_id
for pairing_id, record in _telegram_onboarding_pairings.items()
if record.expires_at_ts <= now
]
for pairing_id in expired:
_telegram_onboarding_pairings.pop(pairing_id, None)
def _normalize_telegram_user_id(value: Any) -> str | None:
normalized = str(value or "").strip()
if _TELEGRAM_USER_ID_RE.fullmatch(normalized):
return normalized
return None
async def _telegram_onboarding_request(
method: str,
path: str,
*,
body: dict[str, Any] | None = None,
bearer_token: str | None = None,
) -> dict[str, Any]:
return await asyncio.to_thread(
_telegram_onboarding_request_sync,
method,
path,
body=body,
bearer_token=bearer_token,
)
@router.post("/api/messaging/telegram/onboarding/start")
async def start_telegram_onboarding(body: TelegramOnboardingStart):
from hermes_cli.web_server import (
_TelegramOnboardingPairing,
_telegram_onboarding_lock,
_telegram_onboarding_pairings,
)
bot_name = (body.bot_name or "Hermes Agent").strip() or "Hermes Agent"
payload = await _telegram_onboarding_request(
"POST",
"/v1/telegram/pairings",
body={"bot_name": bot_name},
)
pairing_id = str(payload.get("pairing_id") or "").strip()
poll_token = str(payload.get("poll_token") or "").strip()
expires_at = str(payload.get("expires_at") or "").strip()
deep_link = str(payload.get("deep_link") or "").strip()
qr_payload = str(payload.get("qr_payload") or deep_link).strip()
suggested_username = str(payload.get("suggested_username") or "").strip()
if not pairing_id or not poll_token or not expires_at or not deep_link:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an incomplete response.",
)
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
_telegram_onboarding_pairings[pairing_id] = _TelegramOnboardingPairing(
poll_token=poll_token,
expires_at=expires_at,
expires_at_ts=_parse_expiry_ts(expires_at),
)
return {
"pairing_id": pairing_id,
"suggested_username": suggested_username,
"deep_link": deep_link,
"qr_payload": qr_payload,
"expires_at": expires_at,
}
@router.get("/api/messaging/telegram/onboarding/{pairing_id}")
async def get_telegram_onboarding_status(pairing_id: str):
from hermes_cli.web_server import _telegram_onboarding_lock, _telegram_onboarding_pairings
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
if record.bot_token:
return {
"status": "ready",
"bot_username": record.bot_username,
"owner_user_id": record.owner_user_id,
"expires_at": record.expires_at,
}
poll_token = record.poll_token
payload = await _telegram_onboarding_request(
"GET",
f"/v1/telegram/pairings/{urllib.parse.quote(pairing_id, safe='')}",
bearer_token=poll_token,
)
status = str(payload.get("status") or "").strip()
if status == "waiting":
with _telegram_onboarding_lock:
current = _telegram_onboarding_pairings.get(pairing_id)
expires_at = current.expires_at if current else ""
return {"status": "waiting", "expires_at": expires_at}
if status == "ready":
bot_token = str(payload.get("token") or "").strip()
bot_username = str(payload.get("bot_username") or "").strip()
if not bot_token:
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an incomplete response.",
)
owner_user_id = _normalize_telegram_user_id(payload.get("owner_user_id"))
with _telegram_onboarding_lock:
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
record.bot_token = bot_token
record.bot_username = bot_username or None
record.owner_user_id = owner_user_id
return {
"status": "ready",
"bot_username": record.bot_username,
"owner_user_id": record.owner_user_id,
"expires_at": record.expires_at,
}
if status in {"expired", "claimed"}:
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
raise HTTPException(
status_code=410,
detail=_telegram_onboarding_error_message(
status,
"Telegram setup is no longer available. Start a new setup.",
),
)
raise HTTPException(
status_code=502,
detail="Telegram setup service returned an unknown status.",
)
def _restart_gateway_after_telegram_onboarding(profile: Optional[str] = None) -> dict[str, Any]:
"""Best-effort gateway restart after saving Telegram QR onboarding.
The QR flow naturally pulls users into Telegram on another device. If the
saved token waits on a separate dashboard restart click, Hermes appears
broken from the chat side. Keep the config save authoritative, but report
restart failures so the UI can fall back to the existing manual banner.
"""
return _restart_gateway_after(profile, what="Telegram onboarding", label="Telegram onboarding")
@router.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply, profile: Optional[str] = None
):
from hermes_cli.web_server import _telegram_onboarding_lock, _telegram_onboarding_pairings
allowed_user_ids = []
seen = set()
for raw_id in body.allowed_user_ids:
normalized = _normalize_telegram_user_id(raw_id)
if not normalized:
raise HTTPException(
status_code=400,
detail="Allowed Telegram user IDs must be numeric.",
)
if normalized not in seen:
seen.add(normalized)
allowed_user_ids.append(normalized)
if not allowed_user_ids:
raise HTTPException(
status_code=400,
detail="Add at least one allowed Telegram user ID.",
)
with _telegram_onboarding_lock:
_prune_telegram_onboarding_pairings()
record = _telegram_onboarding_pairings.get(pairing_id)
if not record:
raise HTTPException(
status_code=404,
detail="Telegram setup session was not found. Start a new setup.",
)
bot_token = record.bot_token
bot_username = record.bot_username
if not bot_token:
raise HTTPException(
status_code=409,
detail="Telegram setup is not ready yet.",
)
effective_profile = body.profile or profile
def _apply():
with _profile_scope(effective_profile):
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
try:
await asyncio.to_thread(_apply)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
_log.exception("Telegram onboarding apply failed")
raise HTTPException(
status_code=500,
detail="Failed to save Telegram setup.",
) from exc
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
restart_result = _restart_gateway_after_telegram_onboarding(effective_profile)
return {
"ok": True,
"platform": "telegram",
"bot_username": bot_username,
"needs_restart": not restart_result["restart_started"],
**restart_result,
}
@router.delete("/api/messaging/telegram/onboarding/{pairing_id}")
async def cancel_telegram_onboarding(pairing_id: str):
from hermes_cli.web_server import _telegram_onboarding_lock, _telegram_onboarding_pairings
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
return {"ok": True}
@router.get("/api/messaging/platforms")
async def get_messaging_platforms(profile: Optional[str] = None):
# Profile-scoped so the dashboard's global profile switcher shows the
# TARGET profile's channel credentials/state, not the root install's.
# load_env() honors the HERMES_HOME contextvar override; the gateway
# status readers do NOT (they resolve process-level paths), so the
# profile directory is passed explicitly for those (#71211).
def _run():
with _profile_scope(profile) as scoped_dir:
env_on_disk = load_env()
runtime = (
read_runtime_status(path=scoped_dir / "gateway_state.json")
if scoped_dir is not None
else read_runtime_status()
)
return {
"env_path": str(get_env_path()),
"gateway_start_command": _gateway_display_command(profile, "start"),
"platforms": [
_messaging_platform_payload(
entry,
env_on_disk,
runtime,
scoped=scoped_dir is not None,
profile_home=scoped_dir,
)
for entry in _messaging_platform_catalog()
]
}
return await asyncio.to_thread(_run)
def _multiplex_port_binding_conflict(
platform_id: str, requested_profile: Optional[str]
) -> Optional[str]:
"""Reason enabling ``platform_id`` on the target profile would break a
multiplexed gateway, or ``None`` when the change is allowed.
Mirrors the gateway's startup rule (``_start_one_profile_adapters`` in
gateway/run.py): with ``gateway.multiplex_profiles`` on, the default
profile owns the single shared HTTP listener and serves every profile via
the ``/p/<profile>/`` prefix, so a SECONDARY profile must never enable a
port-binding platform. Without this pre-write check the dashboard happily
persisted the invalid config and the shared gateway died with
``MultiplexConfigError`` on its next start — for ALL profiles. Only
*enabling* is blocked; disabling/clearing stays allowed so users can
repair an already-invalid profile.
"""
from gateway.config import PORT_BINDING_PLATFORM_VALUES, load_gateway_config
if platform_id not in PORT_BINDING_PLATFORM_VALUES:
return None
requested = (requested_profile or "").strip()
if not requested or requested.lower() == "current":
from hermes_cli.profiles import get_active_profile_name
# The dashboard's own profile. "custom" (an unrecognized HERMES_HOME)
# is outside the profiles tree, so a multiplexed gateway never serves
# it — nothing to guard.
target = get_active_profile_name()
else:
_resolve_profile_dir(requested) # same 400/404 as _profile_scope
target = requested
if target in ("default", "custom"):
return None
# The multiplex flag that matters is the one the shared gateway reads at
# startup: the DEFAULT profile's gateway config (plus the process-wide
# GATEWAY_MULTIPLEX_PROFILES override, which load_gateway_config applies).
with _config_profile_scope("default"):
if not load_gateway_config().multiplex_profiles:
return None
return (
f"Cannot enable '{platform_id}' on profile '{target}': it binds its "
"own listener port, and gateway.multiplex_profiles is on, so the "
"default profile owns the single shared HTTP listener for every "
"profile. Configure this channel on the default profile instead "
"(disabling or clearing it here is still allowed)."
)
@router.put("/api/messaging/platforms/{platform_id}")
async def update_messaging_platform(
platform_id: str, body: MessagingPlatformUpdate, profile: Optional[str] = None
):
entry = _catalog_lookup(platform_id)
if not entry:
raise HTTPException(
status_code=404, detail=f"Unknown messaging platform: {platform_id}"
)
target_profile = body.profile or profile
if body.enabled:
conflict = _multiplex_port_binding_conflict(platform_id, target_profile)
if conflict:
# Reject BEFORE any .env/config.yaml write so the profile stays
# loadable by the multiplexed gateway.
_log.info(
"Rejected messaging platform update: platform=%s profile=%s "
"(multiplex port-binding conflict)",
platform_id,
target_profile or "current",
)
raise HTTPException(status_code=409, detail=conflict)
allowed_env = set(entry["env_vars"])
def _apply():
with _profile_scope(body.profile or profile):
for key in body.clear_env:
if key not in allowed_env:
raise HTTPException(
status_code=400,
detail=f"{key} is not configurable for {entry['name']}",
)
remove_env_value(key)
for key, value in body.env.items():
if key not in allowed_env:
raise HTTPException(
status_code=400,
detail=f"{key} is not configurable for {entry['name']}",
)
trimmed = value.strip()
if trimmed:
_validate_messaging_env_value(platform_id, key, trimmed)
save_env_value(key, trimmed)
if body.enabled is not None:
_write_platform_enabled(platform_id, body.enabled)
try:
await asyncio.to_thread(_apply)
# Audit trail for channel config mutations: names only, never values.
_log.info(
"Messaging platform updated: platform=%s profile=%s enabled=%s "
"env_keys=%s cleared_keys=%s",
platform_id,
target_profile or "current",
body.enabled,
sorted(body.env),
sorted(body.clear_env),
)
return {"ok": True, "platform": platform_id}
except HTTPException:
raise
except Exception:
_log.exception("PUT /api/messaging/platforms/%s failed", platform_id)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/messaging/platforms/{platform_id}/test")
async def test_messaging_platform(platform_id: str, profile: Optional[str] = None):
entry = _catalog_lookup(platform_id)
if not entry:
raise HTTPException(
status_code=404, detail=f"Unknown messaging platform: {platform_id}"
)
def _run():
with _profile_scope(profile) as scoped_dir:
env_on_disk = load_env()
runtime = (
read_runtime_status(path=scoped_dir / "gateway_state.json")
if scoped_dir is not None
else read_runtime_status()
)
return _messaging_platform_payload(
entry,
env_on_disk,
runtime,
scoped=scoped_dir is not None,
profile_home=scoped_dir,
)
payload = await asyncio.to_thread(_run)
if not payload["enabled"]:
message = f"{entry['name']} is disabled. Enable it, then restart the gateway."
return {"ok": False, "state": payload["state"], "message": message}
if not payload["configured"]:
missing = [
field["key"]
for field in payload["env_vars"]
if field["required"] and not field["is_set"]
]
message = (
f"Missing required setup: {', '.join(missing)}"
if missing
else "Platform setup is incomplete."
)
return {"ok": False, "state": payload["state"], "message": message}
if not payload["gateway_running"]:
return {
"ok": False,
"state": payload["state"],
"message": "Gateway is not running. Restart the gateway to connect this platform.",
}
if payload["state"] == "connected":
return {
"ok": True,
"state": payload["state"],
"message": f"{entry['name']} is connected.",
}
if payload.get("error_message"):
return {
"ok": False,
"state": payload["state"],
"message": payload["error_message"],
}
return {
"ok": False,
"state": payload["state"],
"message": "Setup looks complete, but the gateway has not reported a connection yet. Restart the gateway.",
}