Files
hermes-agent/hermes_cli/web_server_messaging.py
Teknium 68e4c2b31a fix(startup): build version-bearing User-Agents when a request is built, not at import
Every CLI start imports all provider plugins (hermes_cli.config lists their env vars), and
fireworks/gmi/xai/router/kimi/opencode plus auth_constants, models, model_catalog,
auxiliary_client, gemini_native_adapter, web_server_messaging, slack, yuanbao and perplexity
built a `<product>/<release>` string at module level. In a source checkout the release comes
from the 7-call git provenance walk (`git tag --merged HEAD` alone is ~0.35 s in a large
clone), so `hermes sessions list` paid it for headers it never sends.

Provider profiles take a LazyHeaders mapping (resolved on first read by the client builders,
which already copy with dict()); core modules call version_info.hermes_user_agent() where the
request is built. Header values are unchanged.
2026-10-25 17:50:59 -07:00

303 lines
13 KiB
Python

"""Messaging-platform catalog and onboarding helpers: platform overrides/env discovery, WhatsApp
and Telegram onboarding state.
"""
import logging
import os
import subprocess
import threading
from dataclasses import dataclass
from fastapi import HTTPException
from pathlib import Path
from typing import Any, Optional
from hermes_cli.config import OPTIONAL_ENV_VARS, write_platform_config_field
from hermes_cli.setup_hidden_env import is_setup_hidden_env as _is_setup_hidden_env
from hermes_cli.version_info import hermes_user_agent
# Same logger the code used before extraction (record parity).
_log = logging.getLogger("hermes_cli.web_server")
# Card metadata for core-only platforms (no plugin, so no PlatformEntry to declare it). Plugin
# platforms declare theirs on their PlatformEntry (``dashboard_*`` / ``docs_url``, see
# :func:`_plugin_card_fields`). Entries omit fields they don't need; the catalog builder fills in
# env_vars from OPTIONAL_ENV_VARS via prefix matching when not specified.
_PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
"api_server": {
"name": "API server",
"description": "Expose Hermes as an OpenAI-compatible HTTP API for tools like Open WebUI.",
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/",
"env_vars": (
"API_SERVER_ENABLED", "API_SERVER_KEY", "API_SERVER_PORT", "API_SERVER_HOST",
"API_SERVER_MODEL_NAME",
),
"required_env": (),
},
"webhook": {
"name": "Webhooks",
"description": "Receive events from GitHub, GitLab, and other webhook sources.",
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/",
"env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"), "required_env": (),
},
"msgraph_webhook": {
"name": "Microsoft Graph Webhook",
"description": "Receive Microsoft Graph change notifications (Teams meetings, Outlook, …).",
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/msgraph-webhook",
"required_env": (),
},
"relay": {
"name": "Relay (experimental)",
"description": "Generic relay adapter fronted by the Hermes Relay connector.",
"docs_url": "", "required_env": (),
},
}
# Display order: well-known platforms surface first; unknown plugins fall to
# the end alphabetically.
_PLATFORM_ORDER: tuple[str, ...] = (
"telegram", "discord", "slack", "mattermost", "matrix", "whatsapp", "signal", "bluebubbles",
"email", "sms", "dingtalk", "feishu", "google_chat", "wecom", "wecom_callback",
"weixin", "qqbot", "yuanbao", "api_server", "webhook",
)
def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]:
"""Build the messaging catalog from the gateway's Platform enum + plugin registry.
Built-ins come from ``gateway.config.Platform`` (LOCAL excluded); plugin platforms from
``platform_registry.plugin_entries()`` so new adapters appear without a code change here.
UI metadata comes from each plugin's PlatformEntry, or :data:`_PLATFORM_OVERRIDES` for core-only
platforms; the rest is derived from id/required_env.
"""
from gateway.config import Platform
# Resolve plugin entries FIRST: plugin platforms leak into ``Platform.__members__`` as
# pseudo-members once anything calls ``Platform("<plugin id>")``, and iterating the enum
# first would claim them with no plugin metadata (nameless "Irc"/"Ntfy" cards).
plugin_map: dict[str, Any] = {}
try:
# Plugin discovery normally runs as a side effect of importing model_tools, which this
# server process doesn't do — trigger it explicitly (idempotent).
from hermes_cli.plugins import discover_plugins
discover_plugins()
from gateway.platform_registry import platform_registry
for plugin_entry in platform_registry.plugin_entries():
plugin_map[plugin_entry.name] = plugin_entry
except Exception:
_log.debug("plugin platform registry unavailable", exc_info=True)
seen: set[str] = set()
entries: list[dict[str, Any]] = []
builtin = [m.value for m in Platform.__members__.values() if m.value != "local"]
for pid in builtin + list(plugin_map):
if pid in seen:
continue
seen.add(pid)
entries.append(_build_catalog_entry(pid, plugin_map.get(pid)))
order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)}
entries.sort(key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower()))
return tuple(entries)
def _channel_managed_env_keys() -> frozenset[str]:
"""Env-var keys owned by a Channels page platform card; the Keys/Env page hides them so the
same fields aren't duplicated. Best-effort: if the catalog can't be built, nothing is hidden."""
try:
return frozenset(k for entry in _messaging_platform_catalog() for k in entry.get("env_vars", ()))
except Exception:
_log.debug("could not build channel-managed env key set", exc_info=True)
return frozenset()
# Cross-cutting gateway / relay knobs stay on the Keys → Settings tab even though
# they use the ``messaging`` category in OPTIONAL_ENV_VARS. Platform-scoped vars
# (``DISCORD_*``, ``MATRIX_*``, …) are owned by the Messaging UI instead.
_MESSAGING_KEYS_PAGE_KEYS = frozenset({
"GATEWAY_ALLOW_ALL_USERS", "GATEWAY_PROXY_KEY", "GATEWAY_PROXY_URL"})
def _platform_env_prefixes(platform_id: str) -> tuple[str, ...]:
"""Env-var prefixes owned by a messaging platform card (shared with the profile-clone
channel stripper so a card and a clone agree on which keys belong to a platform)."""
from hermes_cli.profile_channels import platform_env_prefixes
return platform_env_prefixes(platform_id)
def _discover_platform_env_vars(platform_id: str) -> tuple[str, ...]:
"""All messaging-category env vars for a platform (override + plugin + prefix)."""
prefixes = _platform_env_prefixes(platform_id)
return tuple(sorted({
name for name, info in OPTIONAL_ENV_VARS.items()
if info.get("category") == "messaging"
and name not in _MESSAGING_KEYS_PAGE_KEYS
and not _is_setup_hidden_env(name)
and any(name.startswith(prefix) for prefix in prefixes)}))
def _merge_platform_env_vars(platform_id: str, override: dict[str, Any], plugin_entry: Any | None) -> tuple[str, ...]:
"""Canonical env-var list for a platform card. Required credentials always survive: hiding a
required field would make the platform unconfigurable."""
discovered = _discover_platform_env_vars(platform_id)
if "env_vars" in override:
explicit = tuple(key for key in override["env_vars"] if not _is_setup_hidden_env(key))
return tuple(dict.fromkeys((*explicit, *discovered)))
if plugin_entry is not None and plugin_entry.required_env:
return tuple(dict.fromkeys((*tuple(plugin_entry.required_env), *discovered)))
return discovered
def _plugin_card_fields(plugin_entry: Any) -> dict[str, Any]:
"""A plugin's declared card metadata in override shape; undeclared fields are left out so
the builder falls back exactly as for a platform with no metadata."""
fields: dict[str, Any] = {
"name": plugin_entry.dashboard_label, "description": plugin_entry.dashboard_description,
"docs_url": plugin_entry.docs_url,
}
if plugin_entry.dashboard_env_vars:
fields["env_vars"] = plugin_entry.dashboard_env_vars
if plugin_entry.dashboard_required_env is not None:
fields["required_env"] = plugin_entry.dashboard_required_env
return fields
def _build_catalog_entry(platform_id: str, plugin_entry: Any | None = None) -> dict[str, Any]:
override = _PLATFORM_OVERRIDES.get(platform_id) or (
_plugin_card_fields(plugin_entry) if plugin_entry is not None else {})
if "required_env" in override:
required_env = tuple(override["required_env"])
else:
required_env = tuple(plugin_entry.required_env or ()) if plugin_entry is not None else ()
plugin_label = plugin_entry.label if plugin_entry is not None else None
plugin_hint = (plugin_entry.install_hint or "") if plugin_entry is not None else None
return {
"id": platform_id,
"name": override.get("name") or plugin_label or platform_id.replace("_", " ").title(),
"description": override.get("description") or plugin_hint or "",
"docs_url": override.get("docs_url", ""),
"env_vars": _merge_platform_env_vars(platform_id, override, plugin_entry),
"required_env": required_env,
}
def _write_platform_enabled(platform_id: str, enabled: bool) -> None:
write_platform_config_field(platform_id, "enabled", enabled)
@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
starting_process: bool = False
session_reserved: bool = False
expiry_timer: threading.Timer | None = None
_whatsapp_onboarding_sessions: dict[str, _WhatsAppOnboardingSession] = {}
def _whatsapp_session_path() -> Path:
from hermes_constants import get_hermes_dir
return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session")
_WHATSAPP_PAYLOAD_FIELDS = (
"status", "qr_payload", "expires_at", "mode", "allowed_users", "account_id", "account_name",
"account_phone", "error",
)
def _whatsapp_onboarding_payload(pairing_id: str, record: _WhatsAppOnboardingSession) -> dict[str, Any]:
return {"pairing_id": pairing_id, **{f: getattr(record, f) for f in _WHATSAPP_PAYLOAD_FIELDS}}
def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) -> dict[str, Any]:
from hermes_cli.web_server_gateway import _restart_gateway_after
return _restart_gateway_after(profile, what="WhatsApp onboarding", label="WhatsApp onboarding")
_TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com"
@dataclass
class _TelegramOnboardingPairing:
poll_token: str
expires_at: str
expires_at_ts: float
bot_token: str | None = None
bot_username: str | None = None
owner_user_id: str | None = None
_telegram_onboarding_pairings: dict[str, _TelegramOnboardingPairing] = {}
_telegram_onboarding_lock = threading.RLock()
def _telegram_onboarding_base_url() -> str:
return os.getenv("TELEGRAM_ONBOARDING_URL", _TELEGRAM_ONBOARDING_DEFAULT_URL).strip().rstrip("/")
def _telegram_onboarding_error_message(error: str, fallback: str) -> str:
return {
"not_found": "Telegram pairing was not found. Start a new setup.",
"expired": "Telegram setup expired. Start a new setup.",
"claimed": "Telegram setup was already claimed. Start a new setup.",
"unauthorized": "Telegram setup service rejected this request.",
"telegram_manager_bot_token_not_configured": "Telegram setup service is not configured.",
"telegram_token_fetch_failed": "Telegram could not finish bot setup. Try again.",
}.get(error, fallback)
_TELEGRAM_UNAVAILABLE = "Telegram setup service is unavailable. Try again shortly."
_TELEGRAM_INVALID = "Telegram setup service returned an invalid response."
def _telegram_onboarding_request_sync(
method: str, path: str, *, body: dict[str, Any] | None = None, bearer_token: str | None = None
) -> dict[str, Any]:
import httpx
headers = {"Accept": "application/json", "User-Agent": hermes_user_agent("HermesDashboard")}
request_kwargs: dict[str, Any] = {}
if body is not None:
headers["Content-Type"] = "application/json"
request_kwargs["json"] = body
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
try:
with httpx.Client(timeout=httpx.Timeout(10.0)) as client:
response = client.request(method, f"{_telegram_onboarding_base_url()}{path}", headers=headers, **request_kwargs)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
try:
parsed = exc.response.json()
except Exception:
parsed = {}
error = str(parsed.get("error") or parsed.get("status") or "")
detail = _telegram_onboarding_error_message(error, "Telegram setup service returned an error.")
if error in {"expired", "claimed"}:
status_code = 410
else:
status_code = 404 if exc.response.status_code == 404 else 502
raise HTTPException(status_code=status_code, detail=detail) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=_TELEGRAM_UNAVAILABLE) from exc
try:
parsed = response.json()
except Exception as exc:
raise HTTPException(status_code=502, detail=_TELEGRAM_INVALID) from exc
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail=_TELEGRAM_INVALID)
return parsed