feat: complete plugin platform parity — all 12 integration points
Extends the platform plugin interface from Phase 1 to cover every touchpoint where built-in platforms have hardcoded behavior. - allowed_users_env / allow_all_env: per-platform auth env vars - max_message_length: smart-chunking for send_message tool - pii_safe: session PII redaction flag - emoji: CLI/gateway display - allow_update_command: /update access control send_message tool (tools/send_message_tool.py): - Replaced hardcoded platform_map dict with Platform() call - Added _send_via_adapter() for plugin platforms — routes through live gateway adapter when available - Registry-aware max message length for smart chunking Cron delivery (cron/scheduler.py): - Replaced hardcoded 15-entry platform_map with Platform() call - Plugin platforms now work as cron delivery targets User authorization (gateway/run.py _is_user_authorized): - Registry fallback: checks PlatformEntry.allowed_users_env and allow_all_env when platform not in hardcoded maps - Plugin platforms get per-platform auth support _UPDATE_ALLOWED_PLATFORMS: checks registry allow_update_command flag Channel directory: includes plugin platforms in session enumeration Orphaned config warning: descriptive message when plugin platform is in config but no plugin registered it Gateway weakref: _gateway_runner_ref for cross-module adapter access hermes status: shows plugin platforms with (plugin) tag hermes gateway setup: plugin platforms appear in menu with setup hints hermes_cli/platforms.py: get_all_platforms() merges with registry, platform_label() falls back to registry for plugin names - 8 new tests (extended fields, cron resolution, platforms merge) - Updated 3 tests for new Platform() based resolution - 2829 passed, 24 pre-existing failures, zero new failures
This commit is contained in:
@ -341,26 +341,27 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
|||||||
from tools.send_message_tool import _send_to_platform
|
from tools.send_message_tool import _send_to_platform
|
||||||
from gateway.config import load_gateway_config, Platform
|
from gateway.config import load_gateway_config, Platform
|
||||||
|
|
||||||
platform_map = {
|
# Accept any platform name — built-in names resolve to their enum
|
||||||
"telegram": Platform.TELEGRAM,
|
# member, plugin platform names create dynamic members via _missing_().
|
||||||
"discord": Platform.DISCORD,
|
try:
|
||||||
"slack": Platform.SLACK,
|
platform = Platform(platform_name.lower())
|
||||||
"whatsapp": Platform.WHATSAPP,
|
except (ValueError, KeyError):
|
||||||
"signal": Platform.SIGNAL,
|
msg = f"unknown platform '{platform_name}'"
|
||||||
"matrix": Platform.MATRIX,
|
logger.warning("Job '%s': %s", job["id"], msg)
|
||||||
"mattermost": Platform.MATTERMOST,
|
return msg
|
||||||
"homeassistant": Platform.HOMEASSISTANT,
|
|
||||||
"dingtalk": Platform.DINGTALK,
|
try:
|
||||||
"feishu": Platform.FEISHU,
|
config = load_gateway_config()
|
||||||
"wecom": Platform.WECOM,
|
except Exception as e:
|
||||||
"wecom_callback": Platform.WECOM_CALLBACK,
|
msg = f"failed to load gateway config: {e}"
|
||||||
"weixin": Platform.WEIXIN,
|
logger.error("Job '%s': %s", job["id"], msg)
|
||||||
"email": Platform.EMAIL,
|
return msg
|
||||||
"sms": Platform.SMS,
|
|
||||||
"bluebubbles": Platform.BLUEBUBBLES,
|
pconfig = config.platforms.get(platform)
|
||||||
"qqbot": Platform.QQBOT,
|
if not pconfig or not pconfig.enabled:
|
||||||
"yuanbao": Platform.YUANBAO,
|
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
|
# 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
|
# is a cron delivery. Wrapping is on by default; set cron.wrap_response: false
|
||||||
|
|||||||
@ -86,6 +86,16 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
|||||||
continue
|
continue
|
||||||
platforms[plat_name] = _build_from_sessions(plat_name)
|
platforms[plat_name] = _build_from_sessions(plat_name)
|
||||||
|
|
||||||
|
# Include plugin-registered platforms (dynamic enum members aren't in
|
||||||
|
# Platform.__members__, so the loop above misses them).
|
||||||
|
try:
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
for entry in platform_registry.plugin_entries():
|
||||||
|
if entry.name not in _SKIP_SESSION_DISCOVERY and entry.name not in platforms:
|
||||||
|
platforms[entry.name] = _build_from_sessions(entry.name)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
directory = {
|
directory = {
|
||||||
"updated_at": datetime.now().isoformat(),
|
"updated_at": datetime.now().isoformat(),
|
||||||
"platforms": platforms,
|
"platforms": platforms,
|
||||||
|
|||||||
@ -67,6 +67,28 @@ class PlatformEntry:
|
|||||||
# "builtin" or "plugin"
|
# "builtin" or "plugin"
|
||||||
source: str = "plugin"
|
source: str = "plugin"
|
||||||
|
|
||||||
|
# ── Auth env var names (for _is_user_authorized integration) ──
|
||||||
|
# E.g. "IRC_ALLOWED_USERS" — checked for comma-separated user IDs.
|
||||||
|
allowed_users_env: str = ""
|
||||||
|
# E.g. "IRC_ALLOW_ALL_USERS" — if truthy, all users authorized.
|
||||||
|
allow_all_env: str = ""
|
||||||
|
|
||||||
|
# ── Message limits ──
|
||||||
|
# Max message length for smart-chunking. 0 = no limit.
|
||||||
|
max_message_length: int = 0
|
||||||
|
|
||||||
|
# ── Privacy ──
|
||||||
|
# If True, session descriptions redact PII (phone numbers, etc.)
|
||||||
|
pii_safe: bool = False
|
||||||
|
|
||||||
|
# ── Display ──
|
||||||
|
# Emoji for CLI/gateway display (e.g. "💬")
|
||||||
|
emoji: str = "🔌"
|
||||||
|
|
||||||
|
# Whether this platform should appear in _UPDATE_ALLOWED_PLATFORMS
|
||||||
|
# (allows /update command from this platform).
|
||||||
|
allow_update_command: bool = True
|
||||||
|
|
||||||
|
|
||||||
class PlatformRegistry:
|
class PlatformRegistry:
|
||||||
"""Central registry of platform adapters.
|
"""Central registry of platform adapters.
|
||||||
|
|||||||
@ -782,6 +782,13 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level weak reference to the active GatewayRunner instance.
|
||||||
|
# Used by tools (e.g. send_message) that need to route through a live
|
||||||
|
# adapter for plugin platforms. Set in GatewayRunner.__init__().
|
||||||
|
import weakref as _weakref
|
||||||
|
_gateway_runner_ref: _weakref.ref = lambda: None
|
||||||
|
|
||||||
|
|
||||||
class GatewayRunner:
|
class GatewayRunner:
|
||||||
"""
|
"""
|
||||||
Main gateway controller.
|
Main gateway controller.
|
||||||
@ -806,9 +813,11 @@ class GatewayRunner:
|
|||||||
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
|
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
def __init__(self, config: Optional[GatewayConfig] = None):
|
def __init__(self, config: Optional[GatewayConfig] = None):
|
||||||
|
global _gateway_runner_ref
|
||||||
self.config = config or load_gateway_config()
|
self.config = config or load_gateway_config()
|
||||||
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
|
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
|
||||||
self._warn_if_docker_media_delivery_is_risky()
|
self._warn_if_docker_media_delivery_is_risky()
|
||||||
|
_gateway_runner_ref = _weakref.ref(self)
|
||||||
|
|
||||||
# Load ephemeral config from config.yaml / env vars.
|
# Load ephemeral config from config.yaml / env vars.
|
||||||
# Both are injected at API-call time only and never persisted.
|
# Both are injected at API-call time only and never persisted.
|
||||||
@ -2483,7 +2492,17 @@ class GatewayRunner:
|
|||||||
|
|
||||||
adapter = self._create_adapter(platform, platform_config)
|
adapter = self._create_adapter(platform, platform_config)
|
||||||
if not adapter:
|
if not adapter:
|
||||||
logger.warning("No adapter available for %s", platform.value)
|
# Distinguish between missing builtin deps and missing plugin
|
||||||
|
_pval = platform.value
|
||||||
|
_builtin_names = {m.value for m in Platform.__members__.values()}
|
||||||
|
if _pval not in _builtin_names:
|
||||||
|
logger.warning(
|
||||||
|
"No adapter for '%s' — is the plugin installed? "
|
||||||
|
"(platform is enabled in config.yaml but no plugin registered it)",
|
||||||
|
_pval,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("No adapter available for %s", _pval)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Set up message + fatal error handlers
|
# Set up message + fatal error handlers
|
||||||
@ -3462,6 +3481,19 @@ class GatewayRunner:
|
|||||||
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
|
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Plugin platforms: check the registry for auth env var names
|
||||||
|
if source.platform not in platform_env_map:
|
||||||
|
try:
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
entry = platform_registry.get(source.platform.value)
|
||||||
|
if entry:
|
||||||
|
if entry.allowed_users_env:
|
||||||
|
platform_env_map[source.platform] = entry.allowed_users_env
|
||||||
|
if entry.allow_all_env:
|
||||||
|
platform_allow_all_map[source.platform] = entry.allow_all_env
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||||
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
|
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
|
||||||
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"):
|
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in ("true", "1", "yes"):
|
||||||
@ -8761,8 +8793,16 @@ class GatewayRunner:
|
|||||||
|
|
||||||
# Block non-messaging platforms (API server, webhooks, ACP)
|
# Block non-messaging platforms (API server, webhooks, ACP)
|
||||||
platform = event.source.platform
|
platform = event.source.platform
|
||||||
if platform not in self._UPDATE_ALLOWED_PLATFORMS:
|
_allowed = self._UPDATE_ALLOWED_PLATFORMS
|
||||||
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
|
# Plugin platforms with allow_update_command=True are also allowed
|
||||||
|
if platform not in _allowed:
|
||||||
|
try:
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
entry = platform_registry.get(platform.value)
|
||||||
|
if not entry or not entry.allow_update_command:
|
||||||
|
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
|
||||||
|
except Exception:
|
||||||
|
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
|
||||||
|
|
||||||
if is_managed():
|
if is_managed():
|
||||||
return f"✗ {format_managed_message('update Hermes Agent')}"
|
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||||
|
|||||||
@ -3779,35 +3779,61 @@ def gateway_setup():
|
|||||||
print()
|
print()
|
||||||
print_header("Messaging Platforms")
|
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
|
||||||
|
|
||||||
menu_items = []
|
menu_items = []
|
||||||
for plat in _PLATFORMS:
|
for plat in _PLATFORMS:
|
||||||
status = _platform_status(plat)
|
status = _platform_status(plat)
|
||||||
menu_items.append(f"{plat['label']} ({status})")
|
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.append("Done")
|
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)
|
choice = prompt_choice("Select a platform to configure:", menu_items, len(menu_items) - 1)
|
||||||
|
|
||||||
if choice == len(_PLATFORMS):
|
if choice == _total_platforms:
|
||||||
break
|
break
|
||||||
|
|
||||||
platform = _PLATFORMS[choice]
|
if choice < len(_PLATFORMS):
|
||||||
|
platform = _PLATFORMS[choice]
|
||||||
|
|
||||||
if platform["key"] == "whatsapp":
|
if platform["key"] == "whatsapp":
|
||||||
_setup_whatsapp()
|
_setup_whatsapp()
|
||||||
elif platform["key"] == "signal":
|
elif platform["key"] == "signal":
|
||||||
_setup_signal()
|
_setup_signal()
|
||||||
elif platform["key"] == "weixin":
|
elif platform["key"] == "weixin":
|
||||||
_setup_weixin()
|
_setup_weixin()
|
||||||
elif platform["key"] == "dingtalk":
|
elif platform["key"] == "dingtalk":
|
||||||
_setup_dingtalk()
|
_setup_dingtalk()
|
||||||
elif platform["key"] == "feishu":
|
elif platform["key"] == "feishu":
|
||||||
_setup_feishu()
|
_setup_feishu()
|
||||||
elif platform["key"] == "qqbot":
|
elif platform["key"] == "qqbot":
|
||||||
_setup_qqbot()
|
_setup_qqbot()
|
||||||
elif platform["key"] == "wecom":
|
elif platform["key"] == "wecom":
|
||||||
_setup_wecom()
|
_setup_wecom()
|
||||||
|
else:
|
||||||
|
_setup_standard_platform(platform)
|
||||||
else:
|
else:
|
||||||
_setup_standard_platform(platform)
|
# 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()
|
||||||
|
|
||||||
# ── Post-setup: offer to install/restart gateway ──
|
# ── Post-setup: offer to install/restart gateway ──
|
||||||
any_configured = any(
|
any_configured = any(
|
||||||
|
|||||||
@ -44,6 +44,40 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
|||||||
|
|
||||||
|
|
||||||
def platform_label(key: str, default: str = "") -> str:
|
def platform_label(key: str, default: str = "") -> str:
|
||||||
"""Return the display label for a platform key, or *default*."""
|
"""Return the display label for a platform key, or *default*.
|
||||||
|
|
||||||
|
Checks the static PLATFORMS dict first, then the plugin platform
|
||||||
|
registry for dynamically registered platforms.
|
||||||
|
"""
|
||||||
info = PLATFORMS.get(key)
|
info = PLATFORMS.get(key)
|
||||||
return info.label if info is not None else default
|
if info is not None:
|
||||||
|
return info.label
|
||||||
|
# Check plugin registry
|
||||||
|
try:
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
entry = platform_registry.get(key)
|
||||||
|
if entry:
|
||||||
|
return f"{entry.emoji} {entry.label}" if entry.emoji else entry.label
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_platforms() -> "OrderedDict[str, PlatformInfo]":
|
||||||
|
"""Return PLATFORMS merged with any plugin-registered platforms.
|
||||||
|
|
||||||
|
Plugin platforms are appended after builtins. This is the function
|
||||||
|
that tools_config and skills_config should use for platform menus.
|
||||||
|
"""
|
||||||
|
merged = OrderedDict(PLATFORMS)
|
||||||
|
try:
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
for entry in platform_registry.plugin_entries():
|
||||||
|
if entry.name not in merged:
|
||||||
|
merged[entry.name] = PlatformInfo(
|
||||||
|
label=f"{entry.emoji} {entry.label}" if entry.emoji else entry.label,
|
||||||
|
default_toolset=f"hermes-{entry.name}",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return merged
|
||||||
|
|||||||
@ -91,12 +91,12 @@ def show_status(args):
|
|||||||
"""Show status of all Hermes Agent components."""
|
"""Show status of all Hermes Agent components."""
|
||||||
show_all = getattr(args, 'all', False)
|
show_all = getattr(args, 'all', False)
|
||||||
deep = getattr(args, 'deep', False)
|
deep = getattr(args, 'deep', False)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||||
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
|
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
|
||||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Environment
|
# Environment
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@ -104,7 +104,7 @@ def show_status(args):
|
|||||||
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
|
||||||
print(f" Project: {PROJECT_ROOT}")
|
print(f" Project: {PROJECT_ROOT}")
|
||||||
print(f" Python: {sys.version.split()[0]}")
|
print(f" Python: {sys.version.split()[0]}")
|
||||||
|
|
||||||
env_path = get_env_path()
|
env_path = get_env_path()
|
||||||
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
|
print(f" .env file: {check_mark(env_path.exists())} {'exists' if env_path.exists() else 'not found'}")
|
||||||
|
|
||||||
@ -115,13 +115,13 @@ def show_status(args):
|
|||||||
|
|
||||||
print(f" Model: {_configured_model_label(config)}")
|
print(f" Model: {_configured_model_label(config)}")
|
||||||
print(f" Provider: {_effective_provider_label()}")
|
print(f" Provider: {_effective_provider_label()}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# API Keys
|
# API Keys
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
print()
|
print()
|
||||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||||||
|
|
||||||
keys = {
|
keys = {
|
||||||
"OpenRouter": "OPENROUTER_API_KEY",
|
"OpenRouter": "OPENROUTER_API_KEY",
|
||||||
"OpenAI": "OPENAI_API_KEY",
|
"OpenAI": "OPENAI_API_KEY",
|
||||||
@ -140,7 +140,7 @@ def show_status(args):
|
|||||||
"ElevenLabs": "ELEVENLABS_API_KEY",
|
"ElevenLabs": "ELEVENLABS_API_KEY",
|
||||||
"GitHub": "GITHUB_TOKEN",
|
"GitHub": "GITHUB_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, env_var in keys.items():
|
for name, env_var in keys.items():
|
||||||
value = get_env_value(env_var) or ""
|
value = get_env_value(env_var) or ""
|
||||||
has_key = bool(value)
|
has_key = bool(value)
|
||||||
@ -322,13 +322,13 @@ def show_status(args):
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
print()
|
print()
|
||||||
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
|
||||||
|
|
||||||
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
|
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
|
||||||
terminal_env = os.getenv("TERMINAL_ENV", "")
|
terminal_env = os.getenv("TERMINAL_ENV", "")
|
||||||
if not terminal_env:
|
if not terminal_env:
|
||||||
terminal_env = terminal_cfg.get("backend", "local")
|
terminal_env = terminal_cfg.get("backend", "local")
|
||||||
print(f" Backend: {terminal_env}")
|
print(f" Backend: {terminal_env}")
|
||||||
|
|
||||||
if terminal_env == "ssh":
|
if terminal_env == "ssh":
|
||||||
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
|
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
|
||||||
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
|
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
|
||||||
@ -357,16 +357,16 @@ def show_status(args):
|
|||||||
print(f" Auth detail: {line}")
|
print(f" Auth detail: {line}")
|
||||||
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
|
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
|
||||||
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
|
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
|
||||||
|
|
||||||
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
||||||
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Messaging Platforms
|
# Messaging Platforms
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
print()
|
print()
|
||||||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||||||
|
|
||||||
platforms = {
|
platforms = {
|
||||||
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
||||||
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
|
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
|
||||||
@ -384,7 +384,7 @@ def show_status(args):
|
|||||||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||||
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, (token_var, home_var) in platforms.items():
|
for name, (token_var, home_var) in platforms.items():
|
||||||
token = os.getenv(token_var, "")
|
token = os.getenv(token_var, "")
|
||||||
has_token = bool(token)
|
has_token = bool(token)
|
||||||
@ -401,7 +401,18 @@ def show_status(args):
|
|||||||
status += f" (home: {home_channel})"
|
status += f" (home: {home_channel})"
|
||||||
|
|
||||||
print(f" {name:<12} {check_mark(has_token)} {status}")
|
print(f" {name:<12} {check_mark(has_token)} {status}")
|
||||||
|
|
||||||
|
# Plugin-registered platforms
|
||||||
|
try:
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
for entry in platform_registry.plugin_entries():
|
||||||
|
configured = entry.check_fn()
|
||||||
|
status_str = "configured" if configured else "not configured"
|
||||||
|
label = entry.label
|
||||||
|
print(f" {label:<12} {check_mark(configured)} {status_str} (plugin)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Gateway Status
|
# Gateway Status
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@ -437,13 +448,13 @@ def show_status(args):
|
|||||||
else:
|
else:
|
||||||
print(f" Status: {color('N/A', Colors.DIM)}")
|
print(f" Status: {color('N/A', Colors.DIM)}")
|
||||||
print(" Manager: (not supported on this platform)")
|
print(" Manager: (not supported on this platform)")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Cron Jobs
|
# Cron Jobs
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
print()
|
print()
|
||||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||||
|
|
||||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||||
if jobs_file.exists():
|
if jobs_file.exists():
|
||||||
import json
|
import json
|
||||||
@ -457,13 +468,13 @@ def show_status(args):
|
|||||||
print(" Jobs: (error reading jobs file)")
|
print(" Jobs: (error reading jobs file)")
|
||||||
else:
|
else:
|
||||||
print(" Jobs: 0")
|
print(" Jobs: 0")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Sessions
|
# Sessions
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
print()
|
print()
|
||||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||||
|
|
||||||
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
||||||
if sessions_file.exists():
|
if sessions_file.exists():
|
||||||
import json
|
import json
|
||||||
@ -475,7 +486,7 @@ def show_status(args):
|
|||||||
print(" Active: (error reading sessions file)")
|
print(" Active: (error reading sessions file)")
|
||||||
else:
|
else:
|
||||||
print(" Active: 0")
|
print(" Active: 0")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Deep checks
|
# Deep checks
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@ -511,7 +522,7 @@ def show_status(args):
|
|||||||
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
|
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(color("─" * 60, Colors.DIM))
|
print(color("─" * 60, Colors.DIM))
|
||||||
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
|
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
|
||||||
|
|||||||
@ -490,4 +490,14 @@ def register(ctx):
|
|||||||
validate_config=validate_config,
|
validate_config=validate_config,
|
||||||
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
||||||
install_hint="No extra packages needed (stdlib only)",
|
install_hint="No extra packages needed (stdlib only)",
|
||||||
|
# Auth env vars for _is_user_authorized() integration
|
||||||
|
allowed_users_env="IRC_ALLOWED_USERS",
|
||||||
|
allow_all_env="IRC_ALLOW_ALL_USERS",
|
||||||
|
# IRC line limit after protocol overhead
|
||||||
|
max_message_length=450,
|
||||||
|
# Display
|
||||||
|
emoji="💬",
|
||||||
|
# IRC doesn't have phone numbers to redact
|
||||||
|
pii_safe=False,
|
||||||
|
allow_update_command=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -235,6 +235,17 @@ class TestExtractAttachments(unittest.TestCase):
|
|||||||
mock_cache.assert_called_once()
|
mock_cache.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronDelivery(unittest.TestCase):
|
||||||
|
"""Verify email in cron scheduler platform_map."""
|
||||||
|
|
||||||
|
def test_email_resolves_for_cron(self):
|
||||||
|
"""Email platform resolves via Platform() for cron delivery."""
|
||||||
|
from gateway.config import Platform
|
||||||
|
p = Platform("email")
|
||||||
|
self.assertEqual(p, Platform.EMAIL)
|
||||||
|
self.assertEqual(p.value, "email")
|
||||||
|
|
||||||
|
|
||||||
class TestDispatchMessage(unittest.TestCase):
|
class TestDispatchMessage(unittest.TestCase):
|
||||||
"""Test email message dispatch logic."""
|
"""Test email message dispatch logic."""
|
||||||
|
|
||||||
|
|||||||
@ -265,3 +265,113 @@ class TestGatewayConfigPluginPlatform:
|
|||||||
assert "badconfig" not in connected_values
|
assert "badconfig" not in connected_values
|
||||||
finally:
|
finally:
|
||||||
_reg.unregister("badconfig")
|
_reg.unregister("badconfig")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Extended PlatformEntry fields ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlatformEntryExtendedFields:
|
||||||
|
"""Test the auth, message length, and display fields on PlatformEntry."""
|
||||||
|
|
||||||
|
def test_default_field_values(self):
|
||||||
|
entry = PlatformEntry(
|
||||||
|
name="test",
|
||||||
|
label="Test",
|
||||||
|
adapter_factory=lambda cfg: None,
|
||||||
|
check_fn=lambda: True,
|
||||||
|
)
|
||||||
|
assert entry.allowed_users_env == ""
|
||||||
|
assert entry.allow_all_env == ""
|
||||||
|
assert entry.max_message_length == 0
|
||||||
|
assert entry.pii_safe is False
|
||||||
|
assert entry.emoji == "🔌"
|
||||||
|
assert entry.allow_update_command is True
|
||||||
|
|
||||||
|
def test_custom_auth_fields(self):
|
||||||
|
entry = PlatformEntry(
|
||||||
|
name="irc",
|
||||||
|
label="IRC",
|
||||||
|
adapter_factory=lambda cfg: None,
|
||||||
|
check_fn=lambda: True,
|
||||||
|
allowed_users_env="IRC_ALLOWED_USERS",
|
||||||
|
allow_all_env="IRC_ALLOW_ALL_USERS",
|
||||||
|
max_message_length=450,
|
||||||
|
pii_safe=False,
|
||||||
|
emoji="💬",
|
||||||
|
)
|
||||||
|
assert entry.allowed_users_env == "IRC_ALLOWED_USERS"
|
||||||
|
assert entry.allow_all_env == "IRC_ALLOW_ALL_USERS"
|
||||||
|
assert entry.max_message_length == 450
|
||||||
|
assert entry.emoji == "💬"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cron platform resolution ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCronPlatformResolution:
|
||||||
|
"""Test that cron delivery accepts plugin platform names."""
|
||||||
|
|
||||||
|
def test_builtin_platform_resolves(self):
|
||||||
|
"""Built-in platform names resolve via Platform() call."""
|
||||||
|
p = Platform("telegram")
|
||||||
|
assert p is Platform.TELEGRAM
|
||||||
|
|
||||||
|
def test_plugin_platform_resolves(self):
|
||||||
|
"""Plugin platform names create dynamic enum members."""
|
||||||
|
p = Platform("irc")
|
||||||
|
assert p.value == "irc"
|
||||||
|
|
||||||
|
def test_invalid_platform_type_rejected(self):
|
||||||
|
"""Non-string values are still rejected."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Platform(None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── platforms.py integration ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlatformsMerge:
|
||||||
|
"""Test get_all_platforms() merges with registry."""
|
||||||
|
|
||||||
|
def test_get_all_platforms_includes_builtins(self):
|
||||||
|
from hermes_cli.platforms import get_all_platforms, PLATFORMS
|
||||||
|
merged = get_all_platforms()
|
||||||
|
for key in PLATFORMS:
|
||||||
|
assert key in merged
|
||||||
|
|
||||||
|
def test_get_all_platforms_includes_plugin(self):
|
||||||
|
from hermes_cli.platforms import get_all_platforms
|
||||||
|
from gateway.platform_registry import platform_registry as _reg
|
||||||
|
|
||||||
|
_reg.register(PlatformEntry(
|
||||||
|
name="testmerge",
|
||||||
|
label="TestMerge",
|
||||||
|
adapter_factory=lambda cfg: None,
|
||||||
|
check_fn=lambda: True,
|
||||||
|
source="plugin",
|
||||||
|
emoji="🧪",
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
merged = get_all_platforms()
|
||||||
|
assert "testmerge" in merged
|
||||||
|
assert "TestMerge" in merged["testmerge"].label
|
||||||
|
finally:
|
||||||
|
_reg.unregister("testmerge")
|
||||||
|
|
||||||
|
def test_platform_label_plugin_fallback(self):
|
||||||
|
from hermes_cli.platforms import platform_label
|
||||||
|
from gateway.platform_registry import platform_registry as _reg
|
||||||
|
|
||||||
|
_reg.register(PlatformEntry(
|
||||||
|
name="labeltest",
|
||||||
|
label="LabelTest",
|
||||||
|
adapter_factory=lambda cfg: None,
|
||||||
|
check_fn=lambda: True,
|
||||||
|
source="plugin",
|
||||||
|
emoji="🏷️",
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
label = platform_label("labeltest")
|
||||||
|
assert "LabelTest" in label
|
||||||
|
finally:
|
||||||
|
_reg.unregister("labeltest")
|
||||||
|
|||||||
@ -205,30 +205,12 @@ def _handle_send(args):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps(_error(f"Failed to load gateway config: {e}"))
|
return json.dumps(_error(f"Failed to load gateway config: {e}"))
|
||||||
|
|
||||||
platform_map = {
|
# Accept any platform name — built-in names resolve to their enum
|
||||||
"telegram": Platform.TELEGRAM,
|
# member, plugin platform names create dynamic members via _missing_().
|
||||||
"discord": Platform.DISCORD,
|
try:
|
||||||
"slack": Platform.SLACK,
|
platform = Platform(platform_name)
|
||||||
"whatsapp": Platform.WHATSAPP,
|
except (ValueError, KeyError):
|
||||||
"signal": Platform.SIGNAL,
|
return tool_error(f"Unknown platform: {platform_name}")
|
||||||
"bluebubbles": Platform.BLUEBUBBLES,
|
|
||||||
"qqbot": Platform.QQBOT,
|
|
||||||
"matrix": Platform.MATRIX,
|
|
||||||
"mattermost": Platform.MATTERMOST,
|
|
||||||
"homeassistant": Platform.HOMEASSISTANT,
|
|
||||||
"dingtalk": Platform.DINGTALK,
|
|
||||||
"feishu": Platform.FEISHU,
|
|
||||||
"wecom": Platform.WECOM,
|
|
||||||
"wecom_callback": Platform.WECOM_CALLBACK,
|
|
||||||
"weixin": Platform.WEIXIN,
|
|
||||||
"email": Platform.EMAIL,
|
|
||||||
"sms": Platform.SMS,
|
|
||||||
"yuanbao": Platform.YUANBAO,
|
|
||||||
}
|
|
||||||
platform = platform_map.get(platform_name)
|
|
||||||
if not platform:
|
|
||||||
avail = ", ".join(platform_map.keys())
|
|
||||||
return tool_error(f"Unknown platform: {platform_name}. Available: {avail}")
|
|
||||||
|
|
||||||
pconfig = config.platforms.get(platform)
|
pconfig = config.platforms.get(platform)
|
||||||
if not pconfig or not pconfig.enabled:
|
if not pconfig or not pconfig.enabled:
|
||||||
@ -429,6 +411,27 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_via_adapter(platform, pconfig, chat_id, chunk):
|
||||||
|
"""Send a message via a live gateway adapter (for plugin platforms).
|
||||||
|
|
||||||
|
Falls back to error if no adapter is connected for this platform.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from gateway.run import _gateway_runner_ref
|
||||||
|
runner = _gateway_runner_ref()
|
||||||
|
if runner:
|
||||||
|
adapter = runner.adapters.get(platform)
|
||||||
|
if adapter:
|
||||||
|
from gateway.platforms.base import SendResult
|
||||||
|
result = await adapter.send(chat_id=chat_id, content=chunk)
|
||||||
|
if result.success:
|
||||||
|
return {"success": True, "message_id": result.message_id}
|
||||||
|
return {"error": f"Adapter send failed: {result.error}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Plugin platform send failed: {e}"}
|
||||||
|
return {"error": f"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?"}
|
||||||
|
|
||||||
|
|
||||||
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
|
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
|
||||||
"""Route a message to the appropriate platform sender.
|
"""Route a message to the appropriate platform sender.
|
||||||
|
|
||||||
@ -473,6 +476,16 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
|||||||
if _feishu_available:
|
if _feishu_available:
|
||||||
_MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
|
_MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH
|
||||||
|
|
||||||
|
# Check plugin registry for max_message_length
|
||||||
|
if platform not in _MAX_LENGTHS:
|
||||||
|
try:
|
||||||
|
from gateway.platform_registry import platform_registry
|
||||||
|
entry = platform_registry.get(platform.value)
|
||||||
|
if entry and entry.max_message_length > 0:
|
||||||
|
_MAX_LENGTHS[platform] = entry.max_message_length
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Smart-chunk the message to fit within platform limits.
|
# Smart-chunk the message to fit within platform limits.
|
||||||
# For short messages or platforms without a known limit this is a no-op.
|
# For short messages or platforms without a known limit this is a no-op.
|
||||||
# Telegram measures length in UTF-16 code units, not Unicode codepoints.
|
# Telegram measures length in UTF-16 code units, not Unicode codepoints.
|
||||||
@ -617,7 +630,9 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
|||||||
elif platform == Platform.YUANBAO:
|
elif platform == Platform.YUANBAO:
|
||||||
result = await _send_yuanbao(chat_id, chunk)
|
result = await _send_yuanbao(chat_id, chunk)
|
||||||
else:
|
else:
|
||||||
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
# Plugin platform — route through the gateway's live adapter
|
||||||
|
# if available, otherwise report the error.
|
||||||
|
result = await _send_via_adapter(platform, pconfig, chat_id, chunk)
|
||||||
|
|
||||||
if isinstance(result, dict) and result.get("error"):
|
if isinstance(result, dict) and result.get("error"):
|
||||||
return result
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user