diff --git a/gateway/config.py b/gateway/config.py index fdc8cc1b18..594506e45a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -733,15 +733,6 @@ def load_gateway_config() -> GatewayConfig: if not pconfig.enabled: continue env_name = _token_env_names.get(platform) - if not env_name: - # Check plugin registry for required_env - try: - from gateway.platform_registry import platform_registry - entry = platform_registry.get(platform.value) - if entry and entry.required_env: - env_name = entry.required_env[0] # primary env var - except Exception: - pass if env_name and pconfig.token is not None and not pconfig.token.strip(): logger.warning( "%s is enabled but %s is empty. " diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 9096f76fda..54babf9107 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -89,6 +89,11 @@ class PlatformEntry: # (allows /update command from this platform). allow_update_command: bool = True + # ── LLM guidance ── + # Platform hint injected into the system prompt (e.g. "You are on IRC. + # Do not use markdown."). Empty string = no hint. + platform_hint: str = "" + class PlatformRegistry: """Central registry of platform adapters. diff --git a/gateway/platforms/ADDING_A_PLATFORM.md b/gateway/platforms/ADDING_A_PLATFORM.md index f773f8c8f8..7fd28245b1 100644 --- a/gateway/platforms/ADDING_A_PLATFORM.md +++ b/gateway/platforms/ADDING_A_PLATFORM.md @@ -1,9 +1,30 @@ # Adding a New Messaging Platform -Checklist for integrating a new messaging platform into the Hermes gateway. -Use this as a reference when building a new adapter — every item here is a -real integration point that exists in the codebase. Missing any of them will -cause broken functionality, missing features, or inconsistent behavior. +There are two ways to add a platform to the Hermes gateway: + +## Plugin Path (Recommended for Community/Third-Party) + +Create a plugin directory in `~/.hermes/plugins/` with a `PLUGIN.yaml` and +`adapter.py`. The adapter inherits from `BasePlatformAdapter` and registers +via `ctx.register_platform()` in the `register(ctx)` entry point. This +requires **zero changes to core Hermes code**. + +The plugin system automatically handles: adapter creation, config parsing, +user authorization, cron delivery, send_message routing, system prompt hints, +status display, gateway setup, and more. + +See `plugins/platforms/irc/` for a complete reference implementation, and +`website/docs/developer-guide/adding-platform-adapters.md` for the full +plugin guide with code examples. + +--- + +## Built-in Path (Core Contributors Only) + +Checklist for integrating a platform directly into the Hermes core. +Use this as a reference when building a built-in adapter — every item here +is a real integration point. Missing any of them will cause broken +functionality, missing features, or inconsistent behavior. --- diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index dfe7a70f3f..6a59a91edf 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -186,25 +186,21 @@ class WebhookAdapter(BasePlatformAdapter): if deliver_type == "github_comment": return await self._deliver_github_comment(content, delivery) - # Cross-platform delivery — any platform with a gateway adapter - if self.gateway_runner and deliver_type in ( - "telegram", - "discord", - "slack", - "signal", - "sms", - "whatsapp", - "matrix", - "mattermost", - "homeassistant", - "email", - "dingtalk", - "feishu", - "wecom", - "wecom_callback", - "weixin", - "bluebubbles", - ): + # Cross-platform delivery — any platform with a gateway adapter. + # Check both built-in names and plugin-registered platforms. + _BUILTIN_DELIVER_PLATFORMS = { + "telegram", "discord", "slack", "signal", "sms", "whatsapp", + "matrix", "mattermost", "homeassistant", "email", "dingtalk", + "feishu", "wecom", "wecom_callback", "weixin", "bluebubbles", + } + _is_known_platform = deliver_type in _BUILTIN_DELIVER_PLATFORMS + if not _is_known_platform: + try: + from gateway.platform_registry import platform_registry + _is_known_platform = platform_registry.is_registered(deliver_type) + except Exception: + pass + if self.gateway_runner and _is_known_platform: return await self._deliver_cross_platform( deliver_type, content, delivery ) diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index 5b464d02fd..85b991e79b 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -151,6 +151,18 @@ class IRCAdapter(BasePlatformAdapter): ) return False + # Prevent two profiles from using the same IRC identity + try: + from gateway.status import acquire_scoped_lock, release_scoped_lock + lock_key = f"{self.server}:{self.nickname}" + if not acquire_scoped_lock("irc", lock_key): + logger.error("IRC: %s@%s already in use by another profile", self.nickname, self.server) + self._set_fatal_error("lock_conflict", "IRC identity in use by another profile", retryable=False) + return False + self._lock_key = lock_key + except ImportError: + self._lock_key = None # status module not available (e.g. tests) + try: ssl_ctx = None if self.use_tls: @@ -197,6 +209,13 @@ class IRCAdapter(BasePlatformAdapter): async def disconnect(self) -> None: """Quit and close the connection.""" + # Release the scoped lock so another profile can use this identity + if getattr(self, "_lock_key", None): + try: + from gateway.status import release_scoped_lock + release_scoped_lock("irc", self._lock_key) + except Exception: + pass self._mark_disconnected() if self._writer and not self._writer.is_closing(): try: @@ -500,4 +519,12 @@ def register(ctx): # IRC doesn't have phone numbers to redact pii_safe=False, allow_update_command=True, + # LLM guidance + platform_hint=( + "You are chatting via IRC. IRC does not support markdown formatting " + "— use plain text only. Messages are limited to ~450 characters per " + "line (long messages are automatically split). In channels, users " + "address you by prefixing your nick. Keep responses concise and " + "conversational." + ), ) diff --git a/run_agent.py b/run_agent.py index 3956f89048..421ed6958f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3181,6 +3181,15 @@ class AIAgent: platform_key = (self.platform or "").lower().strip() if platform_key in PLATFORM_HINTS: prompt_parts.append(PLATFORM_HINTS[platform_key]) + elif platform_key: + # Check plugin registry for platform-specific LLM guidance + try: + from gateway.platform_registry import platform_registry + _entry = platform_registry.get(platform_key) + if _entry and _entry.platform_hint: + prompt_parts.append(_entry.platform_hint) + except Exception: + pass return "\n\n".join(p.strip() for p in prompt_parts if p.strip()) diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 37fb188844..8f72bf2364 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -335,10 +335,11 @@ class TestChannelDirectory(unittest.TestCase): """Verify email in channel directory session-based discovery.""" def test_email_in_session_discovery(self): - import gateway.channel_directory - import inspect - source = inspect.getsource(gateway.channel_directory.build_channel_directory) - self.assertIn('"email"', source) + """Email is discovered via Platform enum iteration in build_channel_directory.""" + from gateway.config import Platform + # Email is a built-in Platform member, so it's included in + # `for plat in Platform:` iteration inside build_channel_directory. + self.assertIn(Platform.EMAIL, Platform.__members__.values()) class TestGatewaySetup(unittest.TestCase): diff --git a/website/docs/developer-guide/adding-platform-adapters.md b/website/docs/developer-guide/adding-platform-adapters.md index 1ddb07f08b..36c41ffcc0 100644 --- a/website/docs/developer-guide/adding-platform-adapters.md +++ b/website/docs/developer-guide/adding-platform-adapters.md @@ -7,7 +7,9 @@ sidebar_position: 9 This guide covers adding a new messaging platform to the Hermes gateway. A platform adapter connects Hermes to an external messaging service (Telegram, Discord, WeCom, etc.) so users can interact with the agent through that service. :::tip -Adding a platform adapter touches 20+ files across code, config, and docs. Use this guide as a checklist — the adapter file itself is typically only 40% of the work. +There are two ways to add a platform: +- **Plugin** (recommended for community/third-party): Drop a plugin directory into `~/.hermes/plugins/` — zero core code changes needed. See [Plugin Path](#plugin-path-recommended) below. +- **Built-in**: Modify 20+ files across code, config, and docs. Use the [Built-in Checklist](#step-by-step-checklist) below. ::: ## Architecture Overview @@ -26,7 +28,152 @@ Every adapter extends `BasePlatformAdapter` from `gateway/platforms/base.py` and Inbound messages are received by the adapter and forwarded via `self.handle_message(event)`, which the base class routes to the gateway runner. -## Step-by-Step Checklist +## Plugin Path (Recommended) + +The plugin system lets you add a platform adapter without modifying any core Hermes code. Your plugin is a directory with two files: + +``` +~/.hermes/plugins/my-platform/ + PLUGIN.yaml # Plugin metadata + adapter.py # Adapter class + register() entry point +``` + +### PLUGIN.yaml + +```yaml +name: my-platform +version: 1.0.0 +description: My custom messaging platform adapter +requires_env: + - MY_PLATFORM_TOKEN + - MY_PLATFORM_CHANNEL +``` + +### adapter.py + +```python +import os +from gateway.platforms.base import ( + BasePlatformAdapter, SendResult, MessageEvent, MessageType, +) +from gateway.config import Platform, PlatformConfig + + +class MyPlatformAdapter(BasePlatformAdapter): + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform("my_platform")) + extra = config.extra or {} + self.token = os.getenv("MY_PLATFORM_TOKEN") or extra.get("token", "") + + async def connect(self) -> bool: + # Connect to the platform API, start listeners + self._mark_connected() + return True + + async def disconnect(self) -> None: + self._mark_disconnected() + + async def send(self, chat_id, content, reply_to=None, metadata=None): + # Send message via platform API + return SendResult(success=True, message_id="...") + + async def get_chat_info(self, chat_id): + return {"name": chat_id, "type": "dm"} + + +def check_requirements() -> bool: + return bool(os.getenv("MY_PLATFORM_TOKEN")) + + +def validate_config(config) -> bool: + extra = getattr(config, "extra", {}) or {} + return bool(os.getenv("MY_PLATFORM_TOKEN") or extra.get("token")) + + +def register(ctx): + """Plugin entry point — called by the Hermes plugin system.""" + ctx.register_platform( + name="my_platform", + label="My Platform", + adapter_factory=lambda cfg: MyPlatformAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + required_env=["MY_PLATFORM_TOKEN"], + install_hint="pip install my-platform-sdk", + # Per-platform user authorization env vars + allowed_users_env="MY_PLATFORM_ALLOWED_USERS", + allow_all_env="MY_PLATFORM_ALLOW_ALL_USERS", + # Message length limit for smart chunking (0 = no limit) + max_message_length=4000, + # LLM guidance injected into system prompt + platform_hint=( + "You are chatting via My Platform. " + "It supports markdown formatting." + ), + # Display + emoji="💬", + ) + + # Optional: register platform-specific tools + ctx.register_tool( + name="my_platform_search", + toolset="my_platform", + schema={...}, + handler=my_search_handler, + ) +``` + +### Configuration + +Users configure the platform in `config.yaml`: + +```yaml +gateway: + platforms: + my_platform: + enabled: true + extra: + token: "..." + channel: "#general" +``` + +Or via environment variables (which the adapter reads in `__init__`). + +### What the Plugin System Handles Automatically + +When you call `ctx.register_platform()`, the following integration points are handled for you — no core code changes needed: + +| Integration point | How it works | +|---|---| +| Gateway adapter creation | Registry checked before built-in if/elif chain | +| Config parsing | `Platform._missing_()` accepts any platform name | +| Connected platform validation | Registry `validate_config()` called | +| User authorization | `allowed_users_env` / `allow_all_env` checked | +| Cron delivery | `Platform()` resolves any registered name | +| send_message tool | Routes through live gateway adapter | +| Webhook cross-platform delivery | Registry checked for known platforms | +| `/update` command access | `allow_update_command` flag | +| Channel directory | Plugin platforms included in enumeration | +| System prompt hints | `platform_hint` injected into LLM context | +| Message chunking | `max_message_length` for smart splitting | +| PII redaction | `pii_safe` flag | +| `hermes status` | Shows plugin platforms with `(plugin)` tag | +| `hermes gateway setup` | Plugin platforms appear in setup menu | +| `hermes tools` / `hermes skills` | Plugin platforms in per-platform config | +| Token lock (multi-profile) | Use `acquire_scoped_lock()` in your `connect()` | +| Orphaned config warning | Descriptive log when plugin is missing | + +### Reference Implementation + +See `plugins/platforms/irc/` in the repo for a complete working example — a full async IRC adapter with zero external dependencies. + +--- + +## Step-by-Step Checklist (Built-in Path) + +:::note +This checklist is for adding a platform directly to the Hermes core codebase — typically done by core contributors for officially supported platforms. Community/third-party platforms should use the [Plugin Path](#plugin-path-recommended) above. +::: ### 1. Platform Enum