diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index be2e59c806..ed480d5095 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1477,6 +1477,23 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None: print(" can refuse to start another copy until this process stops.") +def _print_served_ingress_urls(profile: str | None = None) -> None: + """Callback URLs of inbound-port platforms the live multiplexer serves for secondary profiles + (the value to paste into the Twilio / LINE / Teams / BlueBubbles console).""" + try: + from hermes_cli.gateway_multiplex_served import format_ingress_url_lines, served_profile_ingress_urls + urls = served_profile_ingress_urls(profile) + except Exception: + return + if not urls: + return + print() + print("Inbound callback URLs on the shared listener:") + for name, per_platform in sorted(urls.items()): + for line in format_ingress_url_lines(per_platform, indent=f" {name}/" if not profile else " "): + print(line) + + def _print_other_profiles_gateway_status() -> None: """Print other profiles' running gateways at the bottom of ``hermes gateway status``.""" try: @@ -6295,12 +6312,14 @@ def _cmd_status(args): full = getattr(args, "full", False) system = getattr(args, "system", False) snapshot = get_gateway_runtime_snapshot(system=system) + from hermes_cli.profiles import get_active_profile_name _windows_service_installed = is_windows() and _gw_windows().is_installed() if not snapshot.running and named_profile_served_by_running_multiplexer(): # Satellite profile: the default multiplexer is the live inbound process for it. print("✓ Gateway is running via the default-profile multiplexer") print(" Manage it from the default profile: hermes gateway status") + _print_served_ingress_urls(get_active_profile_name()) elif (kind := _installed_service_kind_for(lambda: _windows_service_installed)) is not None: if kind == "systemd": systemd_status(deep, system=system, full=full) @@ -6309,12 +6328,14 @@ def _cmd_status(args): else: _gw_windows().status(deep=deep) _print_gateway_process_mismatch(snapshot) + _print_served_ingress_urls() else: pids = list(snapshot.gateway_pids) if pids: print(f"✓ Gateway is running (PID: {', '.join(map(str, pids))})") print(" (Running manually, not as a system service)") _print_runtime_health() + _print_served_ingress_urls() print() _print_lines(*_STATUS_RUNNING_HINTS[_status_host_kind()]) else: diff --git a/hermes_cli/gateway_multiplex_served.py b/hermes_cli/gateway_multiplex_served.py index 01a8d5e3e1..5f4d6f8d0b 100644 --- a/hermes_cli/gateway_multiplex_served.py +++ b/hermes_cli/gateway_multiplex_served.py @@ -41,3 +41,34 @@ def recorded_served_profiles(default_root: Optional[Path] = None) -> Optional[li def multiplexer_served_secondaries() -> list[str]: """Named profiles the live default multiplexer serves (excludes ``default``); empty when none.""" return [p for p in (recorded_served_profiles() or []) if p and p != "default"] + + +def served_profile_ingress_urls(profile: Optional[str] = None) -> dict[str, dict[str, str]]: + """``{profile: {platform: url}}`` for every secondary inbound-port platform the live multiplexer + serves on its shared listener (``:`` entries carrying ``ingress_url``). This is + what the user pastes into the vendor console (Twilio, LINE, Teams, ...). ``profile`` narrows the map.""" + from hermes_constants import get_default_hermes_root + from gateway.status import read_runtime_status + if live_default_gateway_pid() is None: + return {} + runtime = read_runtime_status(get_default_hermes_root() / "gateway_state.json") or {} + platforms = runtime.get("platforms") + if not isinstance(platforms, dict): + return {} + urls: dict[str, dict[str, str]] = {} + for key, entry in platforms.items(): + if not (isinstance(key, str) and ":" in key and isinstance(entry, dict)): + continue + url = entry.get("ingress_url") + if not url or entry.get("state") in ("fatal", "disconnected", "stopped"): + continue + name, platform = key.split(":", 1) + if profile and name != profile: + continue + urls.setdefault(name, {})[platform] = str(url) + return urls + + +def format_ingress_url_lines(urls: dict[str, str], indent: str = " ") -> list[str]: + """One ``: `` line per platform, sorted.""" + return [f"{indent}{platform}: {url}" for platform, url in sorted(urls.items())] diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 07386ef37f..20bb7b70d0 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -234,6 +234,10 @@ def _render_gateway(ctx): _kv("PID(s):", _format_gateway_pids(snapshot.gateway_pids)) if snapshot.running and (served := multiplexer_served_secondaries()): _kv("Serves:", ", ".join(served)) + from hermes_cli.gateway_multiplex_served import served_profile_ingress_urls + for name, per_platform in sorted(served_profile_ingress_urls().items()): + for platform, url in sorted(per_platform.items()): + _kv(f" {name}/{platform}:", url) if snapshot.has_process_service_mismatch: _kv("Service:", "installed but not managing the current running gateway") elif _is_termux() and not snapshot.gateway_pids: diff --git a/hermes_cli/web_routers/messaging.py b/hermes_cli/web_routers/messaging.py index 88c253bc81..add059122f 100644 --- a/hermes_cli/web_routers/messaging.py +++ b/hermes_cli/web_routers/messaging.py @@ -259,6 +259,8 @@ def _messaging_platform_payload( "gateway_running": gateway_running, "state": state, "error_code": error_code, "error_message": error_message, "updated_at": runtime_platform.get("updated_at"), "home_channel": home_channel, "env_vars": env_vars, + # Multiplex secondary served on the default's shared listener: the vendor callback URL. + "ingress_url": runtime_platform.get("ingress_url") if gateway_running else None, } if platform_id == "whatsapp": whatsapp_mode = env_value("WHATSAPP_MODE").strip() @@ -794,19 +796,17 @@ async def get_messaging_platforms(profile: Optional[str] = None): 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 allowed. + """Reason enabling ``platform_id`` on the target profile is pointless under a multiplexed + gateway, or ``None`` when allowed. - Mirrors ``_start_one_profile_adapters`` (gateway/run.py): with - ``gateway.multiplex_profiles`` on, the default profile owns the single shared - HTTP listener (``/p//``), so a SECONDARY profile must never enable a - port-binding platform or the shared gateway dies with ``MultiplexConfigError`` - for ALL profiles. Only *enabling* is blocked; disabling/clearing stays allowed - so users can repair an invalid profile. + With ``gateway.multiplex_profiles`` on, the default profile's listener already mirrors + ``api_server`` and ``webhook`` at ``/p//`` for every profile, so a SECONDARY must not + enable a second one. Every other inbound-port platform (Twilio, LINE, Teams, ...) IS allowed on a + secondary: the gateway serves it on the shared listener at ``/p//``. """ - from gateway.config import PORT_BINDING_PLATFORM_VALUES, load_gateway_config + from gateway.config import SHARED_LISTENER_MIRROR_PLATFORMS, load_gateway_config - if platform_id not in PORT_BINDING_PLATFORM_VALUES: + if platform_id not in SHARED_LISTENER_MIRROR_PLATFORMS: return None requested = (requested_profile or "").strip() @@ -829,10 +829,9 @@ def _multiplex_port_binding_conflict(platform_id: str, requested_profile: Option 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)." + f"Cannot enable '{platform_id}' on profile '{target}': gateway.multiplex_profiles is on and the " + f"default profile's listener already serves it for every profile at /p/{target}/. Configure it " + "on the default profile instead (disabling or clearing it here is still allowed)." ) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a5275a6ac4..c1e0f55797 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1590,6 +1590,8 @@ export interface MessagingPlatform { error_message: string | null; updated_at: string | null; home_channel: { platform: string; chat_id: string; name: string; thread_id?: string } | null; + /** Multiplex secondary served on the default profile's shared listener: the vendor callback URL. */ + ingress_url?: string | null; whatsapp_setup?: { mode?: string; allowed_users_set?: boolean; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 08d781e306..e6809bc67f 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -567,6 +567,12 @@ export default function ChannelsPage() { {platform.error_message} )} + {platform.ingress_url && ( + + Callback URL (shared listener):{" "} + {platform.ingress_url} + + )}