feat(gateway): unify setup flows, load platforms dynamically from registry
Merge the two gateway setup paths (hermes setup gateway + hermes gateway setup) to use a single _unified_platforms() list that merges built-in _PLATFORMS with dynamically registered plugin entries from platform_registry. - Add setup_fn field to PlatformEntry for plugin setup flows - _unified_platforms() merges built-ins with registry entries by key - setup_gateway() now uses unified list instead of hardcoded _GATEWAY_PLATFORMS tuple list - gateway_setup() uses same unified list, plugin entries appear alongside built-ins with no [plugin] suffix - _platform_status() handles plugin platforms via registry check_fn - Plugin platforms with setup_fn get called directly; plugins without get a generic env-var display fallback IRC and other plugin platforms now appear automatically in the setup menu when registered via platform_registry.register(). feat(gateway): surface disabled platform plugins in setup and auto-enable on select Platform plugins under plugins/platforms/* (IRC, etc.) were gated behind plugins.enabled, so `hermes gateway setup` wouldn't list them until the user ran `hermes plugins enable <name>` first. Now the setup menu always surfaces them as "plugin disabled — select to enable", and picking one adds it to plugins.enabled before running its setup flow. Along the way, unify the two gateway setup flows so `hermes setup gateway` and `hermes gateway setup` both read from the same platform list (built-in _PLATFORMS + platform_registry entries), dispatch through a single _configure_platform() helper, and share _platform_status(). Deletes the dead bespoke wrappers in setup.py (_setup_whatsapp, _setup_weixin, _setup_email, etc.) that duplicated logic now covered by the registry path or _setup_standard_platform. Also: - PlatformEntry gains a plugin_name field so the registry knows which plugin owns each entry (required for auto-enable). - PluginContext.register_platform auto-stamps plugin_name from the manifest so plugins don't have to pass it explicitly. - PluginManager now scans plugins/platforms/* as its own category root, one level below the bundled plugin scan. - Fix IRC plugin discovery: rename PLUGIN.yaml → plugin.yaml (the scanner is case-sensitive) and add the missing __init__.py that _load_directory_module requires.
This commit is contained in:
@@ -341,28 +341,6 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
from tools.send_message_tool import _send_to_platform
|
||||
from gateway.config import load_gateway_config, Platform
|
||||
|
||||
# Accept any platform name — built-in names resolve to their enum
|
||||
# member, plugin platform names create dynamic members via _missing_().
|
||||
try:
|
||||
platform = Platform(platform_name.lower())
|
||||
except (ValueError, KeyError):
|
||||
msg = f"unknown platform '{platform_name}'"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
return msg
|
||||
|
||||
try:
|
||||
config = load_gateway_config()
|
||||
except Exception as e:
|
||||
msg = f"failed to load gateway config: {e}"
|
||||
logger.error("Job '%s': %s", job["id"], msg)
|
||||
return msg
|
||||
|
||||
pconfig = config.platforms.get(platform)
|
||||
if not pconfig or not pconfig.enabled:
|
||||
msg = f"platform '{platform_name}' not configured/enabled"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
return msg
|
||||
|
||||
# Optionally wrap the content with a header/footer so the user knows this
|
||||
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
|
||||
# in config.yaml for clean output.
|
||||
@@ -419,13 +397,23 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
job["id"], platform_name, chat_id, thread_id,
|
||||
)
|
||||
|
||||
platform = platform_map.get(platform_name.lower())
|
||||
if not platform:
|
||||
# Built-in names resolve to their enum member; plugin platform names
|
||||
# create dynamic members via Platform._missing_().
|
||||
try:
|
||||
platform = Platform(platform_name.lower())
|
||||
except (ValueError, KeyError):
|
||||
msg = f"unknown platform '{platform_name}'"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
pconfig = config.platforms.get(platform)
|
||||
if not pconfig or not pconfig.enabled:
|
||||
msg = f"platform '{platform_name}' not configured/enabled"
|
||||
logger.warning("Job '%s': %s", job["id"], msg)
|
||||
delivery_errors.append(msg)
|
||||
continue
|
||||
|
||||
# Prefer the live adapter when the gateway is running — this supports E2EE
|
||||
# rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.
|
||||
runtime_adapter = (adapters or {}).get(platform)
|
||||
|
||||
@@ -64,9 +64,20 @@ class PlatformEntry:
|
||||
# Hint shown when check_fn returns False.
|
||||
install_hint: str = ""
|
||||
|
||||
# Optional setup function for interactive configuration.
|
||||
# Signature: () -> None (prompts user, saves env vars).
|
||||
# If None, falls back to _setup_standard_platform (needs token_var + vars)
|
||||
# or a generic "set these env vars" display.
|
||||
setup_fn: Optional[Callable[[], None]] = None
|
||||
|
||||
# "builtin" or "plugin"
|
||||
source: str = "plugin"
|
||||
|
||||
# Name of the plugin manifest that registered this entry (empty for
|
||||
# built-ins). Used by ``hermes gateway setup`` to auto-enable the
|
||||
# owning plugin when the user configures its platform.
|
||||
plugin_name: str = ""
|
||||
|
||||
# ── Auth env var names (for _is_user_authorized integration) ──
|
||||
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
|
||||
allowed_users_env: str = ""
|
||||
|
||||
@@ -2763,13 +2763,141 @@ _PLATFORMS = [
|
||||
]
|
||||
|
||||
|
||||
def _load_bundled_platform_plugins_for_enumeration() -> set[str]:
|
||||
"""Force-load bundled platform plugins so they appear in setup menus.
|
||||
|
||||
Platform plugins under ``plugins/platforms/`` are opt-in via
|
||||
``plugins.enabled`` like every other plugin, but we want them listed in
|
||||
``hermes gateway setup`` even when disabled so users can discover and
|
||||
enable them inline. ``register()`` on a platform plugin only populates
|
||||
the registry — no adapters run, no network I/O — so loading it here is
|
||||
side-effect-free for the short-lived setup process.
|
||||
|
||||
Returns the set of plugin names that were force-loaded (i.e. plugins
|
||||
not in ``plugins.enabled``), so the caller can display a hint and
|
||||
auto-enable them on selection.
|
||||
"""
|
||||
try:
|
||||
import yaml as _yaml
|
||||
except ImportError:
|
||||
return set()
|
||||
|
||||
from hermes_cli.plugins import (
|
||||
get_bundled_plugins_dir,
|
||||
get_plugin_manager,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
manager = get_plugin_manager()
|
||||
platforms_dir = get_bundled_plugins_dir() / "platforms"
|
||||
if not platforms_dir.is_dir():
|
||||
return set()
|
||||
|
||||
disabled_plugin_names: set[str] = set()
|
||||
for child in sorted(platforms_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
manifest_file = child / "plugin.yaml"
|
||||
if not manifest_file.exists():
|
||||
manifest_file = child / "plugin.yml"
|
||||
if not manifest_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
data = _yaml.safe_load(manifest_file.read_text()) or {}
|
||||
except Exception as e:
|
||||
logger.debug("failed to parse %s: %s", manifest_file, e)
|
||||
continue
|
||||
plugin_name = data.get("name", child.name)
|
||||
|
||||
existing = manager._plugins.get(plugin_name)
|
||||
if existing is not None and existing.enabled:
|
||||
continue # already loaded by normal discovery
|
||||
|
||||
manifest = PluginManifest(
|
||||
name=plugin_name,
|
||||
version=str(data.get("version", "")),
|
||||
description=data.get("description", ""),
|
||||
author=data.get("author", ""),
|
||||
requires_env=data.get("requires_env", []),
|
||||
provides_tools=data.get("provides_tools", []),
|
||||
provides_hooks=data.get("provides_hooks", []),
|
||||
source="bundled",
|
||||
path=str(child),
|
||||
)
|
||||
try:
|
||||
manager._load_plugin(manifest)
|
||||
except Exception as e:
|
||||
logger.debug("failed to force-load %s: %s", plugin_name, e)
|
||||
continue
|
||||
disabled_plugin_names.add(plugin_name)
|
||||
|
||||
return disabled_plugin_names
|
||||
|
||||
|
||||
def _all_platforms() -> list[dict]:
|
||||
"""Return the full list of platforms for setup menus.
|
||||
|
||||
Combines the built-in ``_PLATFORMS`` with plugin platforms registered via
|
||||
``platform_registry``. Plugins are discovered on first call so platforms
|
||||
like IRC appear in ``hermes setup gateway`` without needing the gateway
|
||||
to be running. Built-ins keep their dict shape; plugin entries are
|
||||
adapted to the same shape with ``_registry_entry`` holding the source.
|
||||
"""
|
||||
# Populate the registry so plugin platforms are visible. Idempotent.
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins()
|
||||
except Exception as e:
|
||||
logger.debug("plugin discovery failed during platform enumeration: %s", e)
|
||||
|
||||
# Also surface bundled platform plugins that aren't in `plugins.enabled`
|
||||
# so the setup menu can offer to enable them.
|
||||
disabled_plugin_names = _load_bundled_platform_plugins_for_enumeration()
|
||||
|
||||
platforms = [dict(p) for p in _PLATFORMS]
|
||||
by_key = {p["key"]: p for p in platforms}
|
||||
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
except Exception:
|
||||
return platforms
|
||||
|
||||
for entry in platform_registry.all_entries():
|
||||
if entry.name in by_key:
|
||||
continue # built-in already covers it
|
||||
needs_enable = bool(entry.plugin_name) and entry.plugin_name in disabled_plugin_names
|
||||
platforms.append({
|
||||
"key": entry.name,
|
||||
"label": entry.label,
|
||||
"emoji": entry.emoji,
|
||||
"token_var": entry.required_env[0] if entry.required_env else "",
|
||||
"install_hint": entry.install_hint,
|
||||
"_registry_entry": entry,
|
||||
"needs_enable": needs_enable,
|
||||
})
|
||||
return platforms
|
||||
|
||||
|
||||
def _platform_status(platform: dict) -> str:
|
||||
"""Return a plain-text status string for a platform.
|
||||
|
||||
Returns uncolored text so it can safely be embedded in
|
||||
simple_term_menu items (ANSI codes break width calculation).
|
||||
curses menu items (ANSI codes break width calculation).
|
||||
"""
|
||||
token_var = platform["token_var"]
|
||||
entry = platform.get("_registry_entry")
|
||||
if entry is not None:
|
||||
try:
|
||||
configured = bool(entry.check_fn())
|
||||
except Exception:
|
||||
configured = False
|
||||
if platform.get("needs_enable") and not configured:
|
||||
return "plugin disabled — select to enable"
|
||||
return "configured" if configured else "not configured"
|
||||
|
||||
token_var = platform.get("token_var", "")
|
||||
if not token_var:
|
||||
return "not configured"
|
||||
val = get_env_value(token_var)
|
||||
if token_var == "WHATSAPP_ENABLED":
|
||||
if val and val.lower() == "true":
|
||||
@@ -3727,6 +3855,93 @@ def _setup_signal():
|
||||
print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
|
||||
|
||||
|
||||
def _builtin_setup_fn(key: str):
|
||||
"""Resolve the interactive setup function for a built-in platform key.
|
||||
|
||||
Late-bound to avoid a circular import with ``hermes_cli.setup`` (which
|
||||
imports from this module for the remaining bespoke flows).
|
||||
"""
|
||||
from hermes_cli import setup as _s
|
||||
return {
|
||||
"telegram": _s._setup_telegram,
|
||||
"discord": _s._setup_discord,
|
||||
"slack": _s._setup_slack,
|
||||
"matrix": _s._setup_matrix,
|
||||
"mattermost": _s._setup_mattermost,
|
||||
"bluebubbles": _s._setup_bluebubbles,
|
||||
"webhooks": _s._setup_webhooks,
|
||||
"signal": _setup_signal,
|
||||
"whatsapp": _setup_whatsapp,
|
||||
"weixin": _setup_weixin,
|
||||
"dingtalk": _setup_dingtalk,
|
||||
"feishu": _setup_feishu,
|
||||
"wecom": _setup_wecom,
|
||||
"qqbot": _setup_qqbot,
|
||||
}.get(key)
|
||||
|
||||
|
||||
def _enable_plugin_for_platform(plugin_name: str, platform_label: str) -> None:
|
||||
"""Add *plugin_name* to ``plugins.enabled`` so it loads on next run."""
|
||||
try:
|
||||
from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set
|
||||
except Exception as e:
|
||||
logger.debug("cannot enable plugin %s: %s", plugin_name, e)
|
||||
return
|
||||
enabled = _get_enabled_set()
|
||||
if plugin_name in enabled:
|
||||
return
|
||||
enabled.add(plugin_name)
|
||||
_save_enabled_set(enabled)
|
||||
print()
|
||||
print_success(
|
||||
f"Enabled plugin '{plugin_name}' for {platform_label}. "
|
||||
"Takes effect on next session."
|
||||
)
|
||||
|
||||
|
||||
def _configure_platform(platform: dict) -> None:
|
||||
"""Run the interactive setup flow for a single platform.
|
||||
|
||||
Dispatch order:
|
||||
1. Plugin-provided ``setup_fn`` on the registry entry.
|
||||
2. Built-in setup function matched by platform key.
|
||||
3. ``_setup_standard_platform`` when the entry has a ``vars`` schema.
|
||||
4. Env-var hint fallback for plugins that offer no setup helper.
|
||||
|
||||
If the platform is owned by a plugin that isn't in ``plugins.enabled``,
|
||||
the plugin is added to the allow-list before setup runs.
|
||||
"""
|
||||
entry = platform.get("_registry_entry")
|
||||
if platform.get("needs_enable") and entry is not None and entry.plugin_name:
|
||||
_enable_plugin_for_platform(entry.plugin_name, entry.label)
|
||||
|
||||
if entry is not None and entry.setup_fn is not None:
|
||||
entry.setup_fn()
|
||||
return
|
||||
|
||||
fn = _builtin_setup_fn(platform["key"])
|
||||
if fn is not None:
|
||||
fn()
|
||||
return
|
||||
|
||||
if platform.get("vars"):
|
||||
_setup_standard_platform(platform)
|
||||
return
|
||||
|
||||
# Plugin with no setup helper — show env-var instructions.
|
||||
label = platform.get("label", platform["key"])
|
||||
emoji = platform.get("emoji", "🔌")
|
||||
print()
|
||||
print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN))
|
||||
required = entry.required_env if entry else []
|
||||
if required:
|
||||
print_info(f" Set these env vars in ~/.hermes/.env: {', '.join(required)}")
|
||||
else:
|
||||
print_info(f" Configure {label} in config.yaml under gateway.platforms.{platform['key']}")
|
||||
if platform.get("install_hint"):
|
||||
print_info(f" {platform['install_hint']}")
|
||||
|
||||
|
||||
def gateway_setup():
|
||||
"""Interactive setup for messaging platforms + gateway service."""
|
||||
if is_managed():
|
||||
@@ -3779,61 +3994,19 @@ def gateway_setup():
|
||||
print()
|
||||
print_header("Messaging Platforms")
|
||||
|
||||
# Build menu from built-in platforms + plugin platforms
|
||||
_plugin_entries = []
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
_plugin_entries = platform_registry.plugin_entries()
|
||||
except Exception:
|
||||
pass
|
||||
platforms = _all_platforms()
|
||||
|
||||
menu_items = []
|
||||
for plat in _PLATFORMS:
|
||||
status = _platform_status(plat)
|
||||
menu_items.append(f"{plat['label']} ({status})")
|
||||
for pentry in _plugin_entries:
|
||||
configured = pentry.check_fn()
|
||||
status_str = "configured" if configured else "not configured"
|
||||
menu_items.append(f"{pentry.emoji} {pentry.label} ({status_str}) [plugin]")
|
||||
menu_items = [
|
||||
f"{p['emoji']} {p['label']} ({_platform_status(p)})"
|
||||
for p in platforms
|
||||
]
|
||||
menu_items.append("Done")
|
||||
|
||||
_total_platforms = len(_PLATFORMS) + len(_plugin_entries)
|
||||
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
|
||||
|
||||
if choice == _total_platforms:
|
||||
if choice == len(platforms):
|
||||
break
|
||||
|
||||
if choice < len(_PLATFORMS):
|
||||
platform = _PLATFORMS[choice]
|
||||
|
||||
if platform["key"] == "whatsapp":
|
||||
_setup_whatsapp()
|
||||
elif platform["key"] == "signal":
|
||||
_setup_signal()
|
||||
elif platform["key"] == "weixin":
|
||||
_setup_weixin()
|
||||
elif platform["key"] == "dingtalk":
|
||||
_setup_dingtalk()
|
||||
elif platform["key"] == "feishu":
|
||||
_setup_feishu()
|
||||
elif platform["key"] == "qqbot":
|
||||
_setup_qqbot()
|
||||
elif platform["key"] == "wecom":
|
||||
_setup_wecom()
|
||||
else:
|
||||
_setup_standard_platform(platform)
|
||||
else:
|
||||
# Plugin platform — show env var setup instructions
|
||||
pentry = _plugin_entries[choice - len(_PLATFORMS)]
|
||||
print(f"\n {pentry.label} (plugin platform)")
|
||||
if pentry.required_env:
|
||||
print(f" Required env vars: {', '.join(pentry.required_env)}")
|
||||
print(f" Set these in ~/.hermes/.env or config.yaml gateway.platforms.{pentry.name}.extra")
|
||||
else:
|
||||
print(f" Configure in config.yaml under gateway.platforms.{pentry.name}")
|
||||
if pentry.install_hint:
|
||||
print(f" {pentry.install_hint}")
|
||||
print()
|
||||
_configure_platform(platforms[choice])
|
||||
|
||||
# ── Post-setup: offer to install/restart gateway ──
|
||||
any_configured = any(
|
||||
|
||||
@@ -37,6 +37,7 @@ import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
@@ -47,6 +48,19 @@ from hermes_constants import get_hermes_home
|
||||
from utils import env_var_enabled
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
def get_bundled_plugins_dir() -> Path:
|
||||
"""Locate the bundled ``plugins/`` directory.
|
||||
|
||||
Honours ``HERMES_BUNDLED_PLUGINS`` (set by the Nix wrapper / packaged
|
||||
installs) so read-only store paths are consulted first. Falls back to
|
||||
the in-repo path used during development.
|
||||
"""
|
||||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return Path(__file__).resolve().parent.parent / "plugins"
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover – yaml is optional at import time
|
||||
@@ -456,6 +470,7 @@ class PluginContext:
|
||||
validate_config: Callable | None = None,
|
||||
required_env: list | None = None,
|
||||
install_hint: str = "",
|
||||
**entry_kwargs: Any,
|
||||
) -> None:
|
||||
"""Register a gateway platform adapter.
|
||||
|
||||
@@ -463,6 +478,10 @@ class PluginContext:
|
||||
``BasePlatformAdapter`` subclass instance. The gateway calls
|
||||
``check_fn()`` before instantiation to verify dependencies.
|
||||
|
||||
Extra keyword arguments are forwarded to ``PlatformEntry`` (e.g.
|
||||
``setup_fn``, ``emoji``, ``allowed_users_env``, ``platform_hint``).
|
||||
Unknown keys raise TypeError from the dataclass constructor.
|
||||
|
||||
Example::
|
||||
|
||||
ctx.register_platform(
|
||||
@@ -470,10 +489,13 @@ class PluginContext:
|
||||
label="IRC",
|
||||
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
||||
check_fn=lambda: True,
|
||||
emoji="💬",
|
||||
setup_fn=irc_interactive_setup,
|
||||
)
|
||||
"""
|
||||
from gateway.platform_registry import platform_registry, PlatformEntry
|
||||
|
||||
entry_kwargs.setdefault("plugin_name", self.manifest.name)
|
||||
entry = PlatformEntry(
|
||||
name=name,
|
||||
label=label,
|
||||
@@ -483,6 +505,7 @@ class PluginContext:
|
||||
required_env=required_env or [],
|
||||
install_hint=install_hint,
|
||||
source="plugin",
|
||||
**entry_kwargs,
|
||||
)
|
||||
platform_registry.register(entry)
|
||||
self._manager._plugin_platform_names.add(name)
|
||||
@@ -613,16 +636,19 @@ class PluginManager:
|
||||
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
|
||||
#
|
||||
# ``memory/`` and ``context_engine/`` are skipped at the top level —
|
||||
# they have their own discovery systems. Porting those to the
|
||||
# category-namespace ``kind: exclusive`` model is a future PR.
|
||||
repo_plugins = Path(__file__).resolve().parent.parent / "plugins"
|
||||
# they have their own discovery systems. ``platforms/`` is a category
|
||||
# holding platform adapters (scanned one level deeper below).
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
manifests.extend(
|
||||
self._scan_directory(
|
||||
repo_plugins,
|
||||
source="bundled",
|
||||
skip_names={"memory", "context_engine"},
|
||||
skip_names={"memory", "context_engine", "platforms"},
|
||||
)
|
||||
)
|
||||
manifests.extend(
|
||||
self._scan_directory(repo_plugins / "platforms", source="bundled")
|
||||
)
|
||||
|
||||
# 2. User plugins (~/.hermes/plugins/)
|
||||
user_dir = get_hermes_home() / "plugins"
|
||||
|
||||
@@ -630,10 +630,9 @@ def _plugin_exists(name: str) -> bool:
|
||||
manifest = _read_manifest(child)
|
||||
if manifest.get("name") == name:
|
||||
return True
|
||||
# Bundled: <repo>/plugins/<name>/
|
||||
from pathlib import Path as _P
|
||||
import hermes_cli
|
||||
repo_plugins = _P(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
if repo_plugins.is_dir():
|
||||
candidate = repo_plugins / name
|
||||
if candidate.is_dir() and (
|
||||
@@ -660,8 +659,8 @@ def _discover_all_plugins() -> list:
|
||||
seen: dict = {} # name -> (name, version, description, source, path)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
import hermes_cli
|
||||
repo_plugins = Path(hermes_cli.__file__).resolve().parent.parent / "plugins"
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
|
||||
if not base.is_dir():
|
||||
continue
|
||||
|
||||
@@ -2206,80 +2206,6 @@ def _setup_mattermost():
|
||||
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _setup_whatsapp():
|
||||
"""Configure WhatsApp bridge."""
|
||||
print_header("WhatsApp")
|
||||
existing = get_env_value("WHATSAPP_ENABLED")
|
||||
if existing:
|
||||
print_info("WhatsApp: already enabled")
|
||||
return
|
||||
|
||||
print_info("WhatsApp connects via a built-in bridge (Baileys).")
|
||||
print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.")
|
||||
print()
|
||||
if prompt_yes_no("Enable WhatsApp now?", True):
|
||||
save_env_value("WHATSAPP_ENABLED", "true")
|
||||
print_success("WhatsApp enabled")
|
||||
print_info("Run 'hermes whatsapp' to choose your mode (separate bot number")
|
||||
print_info("or personal self-chat) and pair via QR code.")
|
||||
|
||||
|
||||
def _setup_weixin():
|
||||
"""Configure Weixin (personal WeChat) via iLink Bot API QR login."""
|
||||
from hermes_cli.gateway import _setup_weixin as _gateway_setup_weixin
|
||||
_gateway_setup_weixin()
|
||||
|
||||
|
||||
def _setup_signal():
|
||||
"""Configure Signal via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_signal as _gateway_setup_signal
|
||||
_gateway_setup_signal()
|
||||
|
||||
|
||||
def _setup_email():
|
||||
"""Configure Email via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_email as _gateway_setup_email
|
||||
_gateway_setup_email()
|
||||
|
||||
|
||||
def _setup_sms():
|
||||
"""Configure SMS (Twilio) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_sms as _gateway_setup_sms
|
||||
_gateway_setup_sms()
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_dingtalk as _gateway_setup_dingtalk
|
||||
_gateway_setup_dingtalk()
|
||||
|
||||
|
||||
def _setup_feishu():
|
||||
"""Configure Feishu / Lark via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_feishu as _gateway_setup_feishu
|
||||
_gateway_setup_feishu()
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_yuanbao as _gateway_setup_yuanbao
|
||||
_gateway_setup_yuanbao()
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom as _gateway_setup_wecom
|
||||
_gateway_setup_wecom()
|
||||
|
||||
|
||||
def _setup_wecom_callback():
|
||||
"""Configure WeCom Callback (self-built app) via gateway setup."""
|
||||
from hermes_cli.gateway import _setup_wecom_callback as _gw_setup
|
||||
_gw_setup()
|
||||
|
||||
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
"""Configure BlueBubbles iMessage gateway."""
|
||||
print_header("BlueBubbles (iMessage)")
|
||||
@@ -2397,47 +2323,26 @@ def _setup_webhooks():
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
# Platform registry for the gateway checklist
|
||||
_GATEWAY_PLATFORMS = [
|
||||
("Telegram", "TELEGRAM_BOT_TOKEN", _setup_telegram),
|
||||
("Discord", "DISCORD_BOT_TOKEN", _setup_discord),
|
||||
("Slack", "SLACK_BOT_TOKEN", _setup_slack),
|
||||
("Signal", "SIGNAL_HTTP_URL", _setup_signal),
|
||||
("Email", "EMAIL_ADDRESS", _setup_email),
|
||||
("SMS (Twilio)", "TWILIO_ACCOUNT_SID", _setup_sms),
|
||||
("Matrix", "MATRIX_ACCESS_TOKEN", _setup_matrix),
|
||||
("Mattermost", "MATTERMOST_TOKEN", _setup_mattermost),
|
||||
("WhatsApp", "WHATSAPP_ENABLED", _setup_whatsapp),
|
||||
("DingTalk", "DINGTALK_CLIENT_ID", _setup_dingtalk),
|
||||
("Feishu / Lark", "FEISHU_APP_ID", _setup_feishu),
|
||||
("Yuanbao", "YUANBAO_APP_ID", _setup_yuanbao),
|
||||
("WeCom (Enterprise WeChat)", "WECOM_BOT_ID", _setup_wecom),
|
||||
("WeCom Callback (Self-Built App)", "WECOM_CALLBACK_CORP_ID", _setup_wecom_callback),
|
||||
("Weixin (WeChat)", "WEIXIN_ACCOUNT_ID", _setup_weixin),
|
||||
("BlueBubbles (iMessage)", "BLUEBUBBLES_SERVER_URL", _setup_bluebubbles),
|
||||
("QQ Bot", "QQ_APP_ID", _setup_qqbot),
|
||||
("Webhooks (GitHub, GitLab, etc.)", "WEBHOOK_ENABLED", _setup_webhooks),
|
||||
]
|
||||
|
||||
|
||||
def setup_gateway(config: dict):
|
||||
"""Configure messaging platform integrations."""
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status, _configure_platform
|
||||
|
||||
print_header("Messaging Platforms")
|
||||
print_info("Connect to messaging platforms to chat with Hermes from anywhere.")
|
||||
print_info("Toggle with Space, confirm with Enter.")
|
||||
print()
|
||||
|
||||
# Build checklist items, pre-selecting already-configured platforms
|
||||
platforms = _all_platforms()
|
||||
|
||||
# Build checklist, pre-selecting already-configured platforms.
|
||||
items = []
|
||||
pre_selected = []
|
||||
for i, (name, env_var, _func) in enumerate(_GATEWAY_PLATFORMS):
|
||||
# Matrix has two possible env vars
|
||||
is_configured = bool(get_env_value(env_var))
|
||||
if name == "Matrix" and not is_configured:
|
||||
is_configured = bool(get_env_value("MATRIX_PASSWORD"))
|
||||
label = f"{name} (configured)" if is_configured else name
|
||||
items.append(label)
|
||||
if is_configured:
|
||||
for i, plat in enumerate(platforms):
|
||||
status = _platform_status(plat)
|
||||
items.append(f"{plat['emoji']} {plat['label']} ({status})")
|
||||
if status == "configured":
|
||||
pre_selected.append(i)
|
||||
|
||||
selected = prompt_checklist("Select platforms to configure:", items, pre_selected)
|
||||
@@ -2447,8 +2352,7 @@ def setup_gateway(config: dict):
|
||||
return
|
||||
|
||||
for idx in selected:
|
||||
name, _env_var, setup_func = _GATEWAY_PLATFORMS[idx]
|
||||
setup_func()
|
||||
_configure_platform(platforms[idx])
|
||||
|
||||
# ── Gateway Service Setup ──
|
||||
any_messaging = (
|
||||
@@ -2738,13 +2642,14 @@ def _get_section_config_summary(config: dict, section_key: str) -> Optional[str]
|
||||
return f"max turns: {max_turns}"
|
||||
|
||||
elif section_key == "gateway":
|
||||
platforms = [
|
||||
_gateway_platform_short_label(label)
|
||||
for label, env_var, _ in _GATEWAY_PLATFORMS
|
||||
if get_env_value(env_var)
|
||||
from hermes_cli.gateway import _all_platforms, _platform_status
|
||||
configured = [
|
||||
_gateway_platform_short_label(plat["label"])
|
||||
for plat in _all_platforms()
|
||||
if _platform_status(plat) == "configured"
|
||||
]
|
||||
if platforms:
|
||||
return ", ".join(platforms)
|
||||
if configured:
|
||||
return ", ".join(configured)
|
||||
return None # No platforms configured — section must run
|
||||
|
||||
elif section_key == "tools":
|
||||
|
||||
@@ -3074,10 +3074,12 @@ def _discover_dashboard_plugins() -> list:
|
||||
plugins = []
|
||||
seen_names: set = set()
|
||||
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
bundled_root = get_bundled_plugins_dir()
|
||||
search_dirs = [
|
||||
(get_hermes_home() / "plugins", "user"),
|
||||
(PROJECT_ROOT / "plugins" / "memory", "bundled"),
|
||||
(PROJECT_ROOT / "plugins", "bundled"),
|
||||
(bundled_root / "memory", "bundled"),
|
||||
(bundled_root, "bundled"),
|
||||
]
|
||||
if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||||
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
|
||||
|
||||
3
plugins/platforms/irc/__init__.py
Normal file
3
plugins/platforms/irc/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -163,12 +163,13 @@ def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch,
|
||||
"WEBHOOK_ENABLED": "",
|
||||
}
|
||||
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
|
||||
monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, ""))
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_mod, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False)
|
||||
@@ -201,12 +202,13 @@ def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys):
|
||||
"WEBHOOK_ENABLED": "",
|
||||
}
|
||||
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
|
||||
monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, ""))
|
||||
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr("platform.system", lambda: "Linux")
|
||||
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False)
|
||||
monkeypatch.setattr(gateway_mod, "is_macos", lambda: False)
|
||||
monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False)
|
||||
|
||||
@@ -529,10 +529,16 @@ class TestGetSectionConfigSummary:
|
||||
assert result == "gpt-5"
|
||||
|
||||
def test_gateway_matches_platform_registry(self):
|
||||
"""Every platform in _GATEWAY_PLATFORMS should be recognised by its
|
||||
own env-var sentinel — i.e. the summary must not drift from the
|
||||
"""Every built-in platform should be recognised by its primary
|
||||
env-var sentinel — i.e. the summary must not drift from the
|
||||
registry used by the setup checklist."""
|
||||
for label, env_var, _fn in setup_mod._GATEWAY_PLATFORMS:
|
||||
from hermes_cli.gateway import _PLATFORMS
|
||||
|
||||
for plat in _PLATFORMS:
|
||||
label = plat["label"]
|
||||
env_var = plat.get("token_var")
|
||||
if not env_var:
|
||||
continue
|
||||
def env_side(key, _target=env_var):
|
||||
return "x" if key == _target else ""
|
||||
with patch.object(setup_mod, "get_env_value", side_effect=env_side):
|
||||
|
||||
Reference in New Issue
Block a user