Compare commits
6 Commits
main
...
feat/platf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18e4849e75 | ||
|
|
ece9e34ec6 | ||
|
|
047de4a668 | ||
|
|
17451dc77a | ||
| b4ce9e4ea8 | |||
| 5d5deaa32e |
@ -90,8 +90,9 @@ class HomeChannel:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
|
||||
from hermes_cli.plugins import resolve_platform_id
|
||||
return cls(
|
||||
platform=Platform(data["platform"]),
|
||||
platform=resolve_platform_id(data["platform"]),
|
||||
chat_id=str(data["chat_id"]),
|
||||
name=data.get("name", "Home"),
|
||||
)
|
||||
@ -228,6 +229,20 @@ class GatewayConfig:
|
||||
# Platform configurations
|
||||
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
|
||||
|
||||
# Plugin-registered platform configurations.
|
||||
#
|
||||
# Keyed by string platform name (matching what
|
||||
# PluginContext.register_platform_adapter was called with). Populated
|
||||
# by from_dict() when a platform name in config.yaml doesn't map to
|
||||
# a built-in Platform enum value but IS claimed by a loaded plugin.
|
||||
# The gateway runner iterates this dict alongside `platforms` and
|
||||
# creates adapters via _create_plugin_adapter.
|
||||
#
|
||||
# Held as a separate dict (rather than mixing with `platforms`) so
|
||||
# the closed Platform enum stays closed — no synthetic enum members,
|
||||
# no string-vs-enum sniffing throughout the codebase.
|
||||
plugin_platforms: Dict[str, PlatformConfig] = field(default_factory=dict)
|
||||
|
||||
# Session reset policies by type
|
||||
default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
|
||||
reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict)
|
||||
@ -361,13 +376,31 @@ class GatewayConfig:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
|
||||
# Trigger plugin discovery so register_platform_adapter calls
|
||||
# have populated the plugin manager's adapter registry by the
|
||||
# time we look up unknown platform names below. Idempotent.
|
||||
# Imported inside the method to avoid pulling hermes_cli into
|
||||
# gateway.config's import-time dep graph.
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_platform_adapter
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
# Plugin discovery is optional. If hermes_cli isn't on the
|
||||
# path (e.g. some embedded test scenarios), silently fall
|
||||
# back to the original "skip unknown" behavior.
|
||||
get_plugin_platform_adapter = lambda _name: None # type: ignore[assignment]
|
||||
|
||||
platforms = {}
|
||||
plugin_platforms: Dict[str, PlatformConfig] = {}
|
||||
for platform_name, platform_data in data.get("platforms", {}).items():
|
||||
try:
|
||||
platform = Platform(platform_name)
|
||||
platforms[platform] = PlatformConfig.from_dict(platform_data)
|
||||
except ValueError:
|
||||
pass # Skip unknown platforms
|
||||
# Not a built-in platform. Check if a plugin claims it.
|
||||
if get_plugin_platform_adapter(platform_name) is not None:
|
||||
plugin_platforms[platform_name] = PlatformConfig.from_dict(platform_data)
|
||||
# Else: silently skip — original behavior preserved.
|
||||
|
||||
reset_by_type = {}
|
||||
for type_name, policy_data in data.get("reset_by_type", {}).items():
|
||||
@ -406,6 +439,7 @@ class GatewayConfig:
|
||||
|
||||
return cls(
|
||||
platforms=platforms,
|
||||
plugin_platforms=plugin_platforms,
|
||||
default_reset_policy=default_policy,
|
||||
reset_by_type=reset_by_type,
|
||||
reset_by_platform=reset_by_platform,
|
||||
|
||||
@ -1953,6 +1953,47 @@ class GatewayRunner:
|
||||
"next_retry": time.monotonic() + 30,
|
||||
}
|
||||
|
||||
# Plugin-registered platforms (after the in-tree loop above —
|
||||
# in-tree wins on name collision because it ran first). Plugin
|
||||
# platforms are NOT in the Platform enum, so they don't get the
|
||||
# full reconnection-queue + status-tracking treatment yet —
|
||||
# that's a v1 simplification. If a plugin platform fails on
|
||||
# connect we log + skip without queueing.
|
||||
for plugin_name, platform_config in self.config.plugin_platforms.items():
|
||||
if not platform_config.enabled:
|
||||
continue
|
||||
enabled_platform_count += 1
|
||||
adapter = self._create_plugin_adapter(plugin_name, platform_config)
|
||||
if not adapter:
|
||||
logger.warning("No adapter available for plugin platform %s", plugin_name)
|
||||
continue
|
||||
adapter.set_message_handler(self._handle_message)
|
||||
adapter.set_fatal_error_handler(self._handle_adapter_fatal_error)
|
||||
adapter.set_session_store(self.session_store)
|
||||
adapter.set_busy_session_handler(self._handle_active_session_busy_message)
|
||||
logger.info("Connecting to plugin platform %s...", plugin_name)
|
||||
try:
|
||||
success = await adapter.connect()
|
||||
if success:
|
||||
# Key by adapter.platform (a PluginPlatformIdentifier
|
||||
# instance). Downstream code does ``[p.value for p in
|
||||
# self.adapters.keys()]`` and similar — keying by the
|
||||
# identifier (which has a .value attribute) keeps
|
||||
# plugin platforms structurally compatible with the
|
||||
# rest of the dict instead of mixing strings and
|
||||
# enum members.
|
||||
self.adapters[adapter.platform] = adapter
|
||||
connected_count += 1
|
||||
logger.info("✓ %s (plugin) connected", plugin_name)
|
||||
else:
|
||||
logger.warning("✗ %s (plugin) failed to connect", plugin_name)
|
||||
startup_retryable_errors.append(
|
||||
f"{plugin_name}: failed to connect"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Plugin platform %s: connect raised", plugin_name)
|
||||
startup_retryable_errors.append(f"{plugin_name}: {e}")
|
||||
|
||||
if connected_count == 0:
|
||||
if startup_nonretryable_errors:
|
||||
reason = "; ".join(startup_nonretryable_errors)
|
||||
@ -2421,6 +2462,47 @@ class GatewayRunner:
|
||||
"""Wait for shutdown signal."""
|
||||
await self._shutdown_event.wait()
|
||||
|
||||
def _create_plugin_adapter(
|
||||
self,
|
||||
name: str,
|
||||
config: Any,
|
||||
) -> Optional[BasePlatformAdapter]:
|
||||
"""Create an adapter from a plugin-registered platform.
|
||||
|
||||
Looks up the plugin's registered (adapter_class, requirements_check)
|
||||
tuple via PluginContext.register_platform_adapter, validates
|
||||
requirements, and instantiates. Returns None on any failure
|
||||
(logs the cause).
|
||||
|
||||
Distinct from _create_adapter to keep the in-tree if/elif chain
|
||||
free of plugin concerns; plugin platforms always go through this
|
||||
path.
|
||||
"""
|
||||
from hermes_cli.plugins import get_plugin_platform_adapter
|
||||
entry = get_plugin_platform_adapter(name)
|
||||
if entry is None:
|
||||
logger.warning(
|
||||
"Plugin platform %s: no plugin claims this name. "
|
||||
"Did the plugin's register() function call "
|
||||
"ctx.register_platform_adapter(name=%r, ...)?",
|
||||
name, name,
|
||||
)
|
||||
return None
|
||||
adapter_class, req_check = entry
|
||||
if req_check and not req_check():
|
||||
logger.warning(
|
||||
"Plugin platform %s: requirements_check returned False",
|
||||
name,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return adapter_class(config)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Plugin platform %s: adapter __init__ raised", name,
|
||||
)
|
||||
return None
|
||||
|
||||
def _create_adapter(
|
||||
self,
|
||||
platform: Platform,
|
||||
|
||||
@ -123,8 +123,9 @@ class SessionSource:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
|
||||
from hermes_cli.plugins import resolve_platform_id
|
||||
return cls(
|
||||
platform=Platform(data["platform"]),
|
||||
platform=resolve_platform_id(data["platform"]),
|
||||
chat_id=str(data["chat_id"]),
|
||||
chat_name=data.get("chat_name"),
|
||||
chat_type=data.get("chat_type", "dm"),
|
||||
@ -409,10 +410,8 @@ class SessionEntry:
|
||||
|
||||
platform = None
|
||||
if data.get("platform"):
|
||||
try:
|
||||
platform = Platform(data["platform"])
|
||||
except ValueError as e:
|
||||
logger.debug("Unknown platform value %r: %s", data["platform"], e)
|
||||
from hermes_cli.plugins import resolve_platform_id
|
||||
platform = resolve_platform_id(data["platform"])
|
||||
|
||||
return cls(
|
||||
session_key=data["session_key"],
|
||||
|
||||
@ -341,6 +341,41 @@ class PluginContext:
|
||||
self._manager._hooks.setdefault(hook_name, []).append(callback)
|
||||
logger.debug("Plugin %s registered hook: %s", self.manifest.name, hook_name)
|
||||
|
||||
# -- platform adapter registration --------------------------------------
|
||||
|
||||
def register_platform_adapter(
|
||||
self,
|
||||
name: str,
|
||||
adapter_class: type,
|
||||
requirements_check: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
"""Register a custom messaging platform adapter.
|
||||
|
||||
``name`` is the identifier the user puts under
|
||||
``gateway.platforms.<name>`` in config.yaml. ``adapter_class``
|
||||
must be a subclass of ``gateway.platforms.base.BasePlatformAdapter``.
|
||||
``requirements_check`` is an optional callable returning False
|
||||
when the adapter's dependencies aren't installed (mirrors the
|
||||
existing ``check_telegram_requirements`` pattern).
|
||||
|
||||
Plugin-registered adapters are consulted as a FALLBACK after
|
||||
``GatewayRunner._create_adapter`` exhausts its hardcoded chain
|
||||
of in-tree platforms — in-tree wins on name collision so this
|
||||
can't shadow Telegram, Slack, etc.
|
||||
"""
|
||||
if name in self._manager._plugin_platform_adapters:
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register platform adapter '%s' but it is "
|
||||
"already registered by another plugin. Skipping.",
|
||||
self.manifest.name, name,
|
||||
)
|
||||
return
|
||||
self._manager._plugin_platform_adapters[name] = (adapter_class, requirements_check)
|
||||
logger.debug(
|
||||
"Plugin %s registered platform adapter: %s",
|
||||
self.manifest.name, name,
|
||||
)
|
||||
|
||||
# -- skill registration -------------------------------------------------
|
||||
|
||||
def register_skill(
|
||||
@ -407,6 +442,10 @@ class PluginManager:
|
||||
self._cli_ref = None # Set by CLI after plugin discovery
|
||||
# Plugin skill registry: qualified name → metadata dict.
|
||||
self._plugin_skills: Dict[str, Dict[str, Any]] = {}
|
||||
# Plugin platform adapter registry: name → (adapter_class, optional req_check).
|
||||
# Consulted by GatewayRunner._create_adapter as a fallback after the
|
||||
# in-tree if/elif chain. See PluginContext.register_platform_adapter.
|
||||
self._plugin_platform_adapters: Dict[str, tuple] = {}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public
|
||||
@ -790,6 +829,86 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]:
|
||||
return entry["handler"] if entry else None
|
||||
|
||||
|
||||
def get_plugin_platform_adapter(name: str) -> Optional[tuple]:
|
||||
"""Return ``(adapter_class, requirements_check_or_None)`` for a
|
||||
plugin-registered platform adapter, or ``None`` if no plugin claims
|
||||
that platform name.
|
||||
|
||||
Used by ``GatewayRunner._create_adapter`` as a fallback after the
|
||||
in-tree if/elif chain — see ``PluginContext.register_platform_adapter``.
|
||||
"""
|
||||
return get_plugin_manager()._plugin_platform_adapters.get(name)
|
||||
|
||||
|
||||
class PluginPlatformIdentifier:
|
||||
"""Platform-enum-shaped identifier for plugin-registered platforms.
|
||||
|
||||
Built-in adapters pass ``Platform.TELEGRAM`` etc. as the second arg
|
||||
to ``BasePlatformAdapter.__init__``. Plugin adapters can't extend
|
||||
the closed Platform enum, so they pass an instance of this class
|
||||
instead — it quacks like ``Platform`` enough for the base class
|
||||
(``.value`` attribute, hashable, equality on value).
|
||||
|
||||
Usage in a plugin adapter::
|
||||
|
||||
from hermes_cli.plugins import PluginPlatformIdentifier
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
class MyAdapter(BasePlatformAdapter):
|
||||
def __init__(self, config):
|
||||
super().__init__(config, PluginPlatformIdentifier("my-platform"))
|
||||
|
||||
The ``name`` should match what was passed to
|
||||
``ctx.register_platform_adapter(name=..., ...)``.
|
||||
"""
|
||||
|
||||
__slots__ = ("value",)
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.value = name
|
||||
|
||||
def __hash__(self) -> int:
|
||||
# Distinct hash bucket so a plugin platform named "telegram"
|
||||
# (impossible — registration is rejected — but defensively)
|
||||
# never collides with Platform.TELEGRAM in dict keys.
|
||||
return hash(("__plugin_platform__", self.value))
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return (
|
||||
isinstance(other, PluginPlatformIdentifier)
|
||||
and self.value == other.value
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"PluginPlatformIdentifier({self.value!r})"
|
||||
|
||||
|
||||
def resolve_platform_id(value: str):
|
||||
"""Resolve a platform-name string to a ``Platform`` enum member or
|
||||
a ``PluginPlatformIdentifier``.
|
||||
|
||||
Used by ``from_dict`` deserializers for ``SessionSource``,
|
||||
``SessionEntry``, ``HomeChannel`` etc. Without this fallback,
|
||||
daemon restart would lose every session whose platform was
|
||||
contributed by a plugin (e.g., ``molecule-a2a``) because the
|
||||
bare ``Platform(value)`` call raises ``ValueError`` for unknown
|
||||
enum members.
|
||||
|
||||
The fallback is intentionally narrow: an unknown name is only
|
||||
accepted as a plugin identifier if a currently-loaded plugin has
|
||||
actually claimed that name via ``register_platform_adapter``. Bare
|
||||
typos and corrupted state still raise ``ValueError`` as before, so
|
||||
silent state-corruption doesn't slip past restore.
|
||||
"""
|
||||
from gateway.config import Platform # late import to avoid cycle
|
||||
try:
|
||||
return Platform(value)
|
||||
except ValueError:
|
||||
if get_plugin_platform_adapter(value) is not None:
|
||||
return PluginPlatformIdentifier(value)
|
||||
raise
|
||||
|
||||
|
||||
def get_plugin_commands() -> Dict[str, dict]:
|
||||
"""Return the full plugin commands dict (name → {handler, description, plugin}).
|
||||
|
||||
|
||||
@ -516,7 +516,15 @@ def _get_platform_tools(
|
||||
toolset_names = platform_toolsets.get(platform)
|
||||
|
||||
if toolset_names is None or not isinstance(toolset_names, list):
|
||||
default_ts = PLATFORMS[platform]["default_toolset"]
|
||||
# Plugin platforms (registered via PluginContext.register_platform_adapter)
|
||||
# aren't in the static PLATFORMS registry — fall back to the cli
|
||||
# toolset, which is the most permissive in-tree default. Operators
|
||||
# can override per-platform via config.platform_toolsets.
|
||||
platform_info = PLATFORMS.get(platform)
|
||||
if platform_info is None:
|
||||
default_ts = "hermes-cli"
|
||||
else:
|
||||
default_ts = platform_info["default_toolset"]
|
||||
toolset_names = [default_ts]
|
||||
|
||||
# YAML may parse bare numeric names (e.g. ``12306:``) as int.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user