mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
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:
parent
8f144fe36b
commit
2e20f6ae2d
11 changed files with 376 additions and 86 deletions
|
|
@ -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 gateway.config import load_gateway_config, Platform
|
||||
|
||||
platform_map = {
|
||||
"telegram": Platform.TELEGRAM,
|
||||
"discord": Platform.DISCORD,
|
||||
"slack": Platform.SLACK,
|
||||
"whatsapp": Platform.WHATSAPP,
|
||||
"signal": Platform.SIGNAL,
|
||||
"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,
|
||||
"bluebubbles": Platform.BLUEBUBBLES,
|
||||
"qqbot": Platform.QQBOT,
|
||||
"yuanbao": Platform.YUANBAO,
|
||||
}
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -86,6 +86,16 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
|||
continue
|
||||
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 = {
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"platforms": platforms,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,28 @@ class PlatformEntry:
|
|||
# "builtin" or "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:
|
||||
"""Central registry of platform adapters.
|
||||
|
|
|
|||
|
|
@ -782,6 +782,13 @@ def _format_gateway_process_notification(evt: dict) -> "str | 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:
|
||||
"""
|
||||
Main gateway controller.
|
||||
|
|
@ -806,9 +813,11 @@ class GatewayRunner:
|
|||
_session_reasoning_overrides: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def __init__(self, config: Optional[GatewayConfig] = None):
|
||||
global _gateway_runner_ref
|
||||
self.config = config or load_gateway_config()
|
||||
self.adapters: Dict[Platform, BasePlatformAdapter] = {}
|
||||
self._warn_if_docker_media_delivery_is_risky()
|
||||
_gateway_runner_ref = _weakref.ref(self)
|
||||
|
||||
# Load ephemeral config from config.yaml / env vars.
|
||||
# Both are injected at API-call time only and never persisted.
|
||||
|
|
@ -2483,7 +2492,17 @@ class GatewayRunner:
|
|||
|
||||
adapter = self._create_adapter(platform, platform_config)
|
||||
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
|
||||
|
||||
# Set up message + fatal error handlers
|
||||
|
|
@ -3462,6 +3481,19 @@ class GatewayRunner:
|
|||
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)
|
||||
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"):
|
||||
|
|
@ -8761,8 +8793,16 @@ class GatewayRunner:
|
|||
|
||||
# Block non-messaging platforms (API server, webhooks, ACP)
|
||||
platform = event.source.platform
|
||||
if platform not in self._UPDATE_ALLOWED_PLATFORMS:
|
||||
return "✗ /update is only available from messaging platforms. Run `hermes update` from the terminal."
|
||||
_allowed = self._UPDATE_ALLOWED_PLATFORMS
|
||||
# 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():
|
||||
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||
|
|
|
|||
|
|
@ -3779,35 +3779,61 @@ 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
|
||||
|
||||
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.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 == len(_PLATFORMS):
|
||||
if choice == _total_platforms:
|
||||
break
|
||||
|
||||
platform = _PLATFORMS[choice]
|
||||
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()
|
||||
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:
|
||||
_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 ──
|
||||
any_configured = any(
|
||||
|
|
|
|||
|
|
@ -44,6 +44,40 @@ PLATFORMS: OrderedDict[str, PlatformInfo] = OrderedDict([
|
|||
|
||||
|
||||
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)
|
||||
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_all = getattr(args, 'all', False)
|
||||
deep = getattr(args, 'deep', False)
|
||||
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
|
||||
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Environment
|
||||
# =========================================================================
|
||||
|
|
@ -104,7 +104,7 @@ def show_status(args):
|
|||
print(color("◆ Environment", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Project: {PROJECT_ROOT}")
|
||||
print(f" Python: {sys.version.split()[0]}")
|
||||
|
||||
|
||||
env_path = get_env_path()
|
||||
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" Provider: {_effective_provider_label()}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API Keys
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
keys = {
|
||||
"OpenRouter": "OPENROUTER_API_KEY",
|
||||
"OpenAI": "OPENAI_API_KEY",
|
||||
|
|
@ -140,7 +140,7 @@ def show_status(args):
|
|||
"ElevenLabs": "ELEVENLABS_API_KEY",
|
||||
"GitHub": "GITHUB_TOKEN",
|
||||
}
|
||||
|
||||
|
||||
for name, env_var in keys.items():
|
||||
value = get_env_value(env_var) or ""
|
||||
has_key = bool(value)
|
||||
|
|
@ -322,13 +322,13 @@ def show_status(args):
|
|||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Terminal Backend", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
terminal_cfg = config.get("terminal", {}) if isinstance(config.get("terminal"), dict) else {}
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "")
|
||||
if not terminal_env:
|
||||
terminal_env = terminal_cfg.get("backend", "local")
|
||||
print(f" Backend: {terminal_env}")
|
||||
|
||||
|
||||
if terminal_env == "ssh":
|
||||
ssh_host = os.getenv("TERMINAL_SSH_HOST", "")
|
||||
ssh_user = os.getenv("TERMINAL_SSH_USER", "")
|
||||
|
|
@ -357,16 +357,16 @@ def show_status(args):
|
|||
print(f" Auth detail: {line}")
|
||||
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
|
||||
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
|
||||
|
||||
|
||||
sudo_password = os.getenv("SUDO_PASSWORD", "")
|
||||
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Messaging Platforms
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
platforms = {
|
||||
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
|
||||
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
|
||||
|
|
@ -384,7 +384,7 @@ def show_status(args):
|
|||
"QQBot": ("QQ_APP_ID", "QQ_HOME_CHANNEL"),
|
||||
"Yuanbao": ("YUANBAO_APP_ID", "YUANBAO_HOME_CHANNEL"),
|
||||
}
|
||||
|
||||
|
||||
for name, (token_var, home_var) in platforms.items():
|
||||
token = os.getenv(token_var, "")
|
||||
has_token = bool(token)
|
||||
|
|
@ -401,7 +401,18 @@ def show_status(args):
|
|||
status += f" (home: {home_channel})"
|
||||
|
||||
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
|
||||
# =========================================================================
|
||||
|
|
@ -437,13 +448,13 @@ def show_status(args):
|
|||
else:
|
||||
print(f" Status: {color('N/A', Colors.DIM)}")
|
||||
print(" Manager: (not supported on this platform)")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cron Jobs
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Scheduled Jobs", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||
if jobs_file.exists():
|
||||
import json
|
||||
|
|
@ -457,13 +468,13 @@ def show_status(args):
|
|||
print(" Jobs: (error reading jobs file)")
|
||||
else:
|
||||
print(" Jobs: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sessions
|
||||
# =========================================================================
|
||||
print()
|
||||
print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
sessions_file = get_hermes_home() / "sessions" / "sessions.json"
|
||||
if sessions_file.exists():
|
||||
import json
|
||||
|
|
@ -475,7 +486,7 @@ def show_status(args):
|
|||
print(" Active: (error reading sessions file)")
|
||||
else:
|
||||
print(" Active: 0")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Deep checks
|
||||
# =========================================================================
|
||||
|
|
@ -511,7 +522,7 @@ def show_status(args):
|
|||
print(f" Port 18789: {'in use' if port_in_use else 'available'}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
print()
|
||||
print(color("─" * 60, Colors.DIM))
|
||||
print(color(" Run 'hermes doctor' for detailed diagnostics", Colors.DIM))
|
||||
|
|
|
|||
|
|
@ -490,4 +490,14 @@ def register(ctx):
|
|||
validate_config=validate_config,
|
||||
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
"""Test email message dispatch logic."""
|
||||
|
||||
|
|
|
|||
|
|
@ -265,3 +265,113 @@ class TestGatewayConfigPluginPlatform:
|
|||
assert "badconfig" not in connected_values
|
||||
finally:
|
||||
_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:
|
||||
return json.dumps(_error(f"Failed to load gateway config: {e}"))
|
||||
|
||||
platform_map = {
|
||||
"telegram": Platform.TELEGRAM,
|
||||
"discord": Platform.DISCORD,
|
||||
"slack": Platform.SLACK,
|
||||
"whatsapp": Platform.WHATSAPP,
|
||||
"signal": Platform.SIGNAL,
|
||||
"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}")
|
||||
# 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)
|
||||
except (ValueError, KeyError):
|
||||
return tool_error(f"Unknown platform: {platform_name}")
|
||||
|
||||
pconfig = config.platforms.get(platform)
|
||||
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):
|
||||
"""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:
|
||||
_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.
|
||||
# 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.
|
||||
|
|
@ -617,7 +630,9 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
|||
elif platform == Platform.YUANBAO:
|
||||
result = await _send_yuanbao(chat_id, chunk)
|
||||
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"):
|
||||
return result
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue