feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
"""
|
|
|
|
|
IRC Platform Adapter for Hermes Agent.
|
|
|
|
|
|
|
|
|
|
A plugin-based gateway adapter that connects to an IRC server and relays
|
|
|
|
|
messages to/from the Hermes agent. Zero external dependencies — uses
|
|
|
|
|
Python's stdlib asyncio for the IRC protocol.
|
|
|
|
|
|
|
|
|
|
Configuration in config.yaml::
|
|
|
|
|
|
|
|
|
|
gateway:
|
|
|
|
|
platforms:
|
|
|
|
|
irc:
|
|
|
|
|
enabled: true
|
|
|
|
|
extra:
|
|
|
|
|
server: irc.libera.chat
|
|
|
|
|
port: 6697
|
|
|
|
|
nickname: hermes-bot
|
|
|
|
|
channel: "#hermes"
|
|
|
|
|
use_tls: true
|
|
|
|
|
server_password: "" # optional server password
|
|
|
|
|
nickserv_password: "" # optional NickServ identification
|
|
|
|
|
allowed_users: [] # empty = allow all, or list of nicks
|
|
|
|
|
max_message_length: 450 # IRC line limit (safe default)
|
|
|
|
|
|
|
|
|
|
Or via environment variables (overrides config.yaml):
|
|
|
|
|
IRC_SERVER, IRC_PORT, IRC_NICKNAME, IRC_CHANNEL, IRC_USE_TLS,
|
|
|
|
|
IRC_SERVER_PASSWORD, IRC_NICKSERV_PASSWORD
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import ssl
|
|
|
|
|
import time
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Lazy import: BasePlatformAdapter and friends live in the main repo.
|
|
|
|
|
# We import at function/class level to avoid import errors when the plugin
|
|
|
|
|
# is discovered but the gateway hasn't been fully initialised yet.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
from gateway.platforms.base import (
|
|
|
|
|
BasePlatformAdapter,
|
|
|
|
|
SendResult,
|
|
|
|
|
MessageEvent,
|
|
|
|
|
MessageType,
|
|
|
|
|
)
|
chore: prune unused imports and duplicate import redefinitions
Remove unused imports (F401) and duplicate/shadowed import
redefinitions (F811) across the codebase using ruff's safe
autofixes. No behavioral changes -- imports only.
- ~1400 safe autofixes applied across 644 files (net -1072 lines)
- __init__.py re-exports preserved (excluded from F401 removal so
public re-export surfaces stay intact)
- Re-exports that are imported or monkeypatched by tests but look
unused in their defining module are kept with explicit # noqa:
F401 (gateway/run.py load_dotenv; run_agent re-exports from
agent.message_sanitization, agent.context_compressor,
agent.retry_utils, agent.prompt_builder, agent.process_bootstrap,
agent.codex_responses_adapter)
- Unsafe F841 (unused-variable) fixes deliberately skipped -- those
can change behavior when the RHS has side effects
- ruff lints remain disabled in pyproject.toml (only PLW1514 is
selected); this is a one-time cleanup, not a config change
Verification:
- python -m compileall: clean
- pytest --collect-only: all 27161 tests collect (zero import errors)
- core entry points import clean (run_agent, model_tools, cli,
toolsets, hermes_state, batch_runner, gateway)
- static scan: every name any test imports directly from an edited
module still resolves
2026-05-29 02:04:58 +05:30
|
|
|
from gateway.config import Platform
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# IRC protocol helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _parse_irc_message(raw: str) -> dict:
|
|
|
|
|
"""Parse a raw IRC protocol line into components.
|
|
|
|
|
|
|
|
|
|
Returns dict with keys: prefix, command, params.
|
|
|
|
|
"""
|
|
|
|
|
prefix = ""
|
|
|
|
|
trailing = ""
|
|
|
|
|
|
|
|
|
|
if raw.startswith(":"):
|
2026-04-20 18:52:15 -04:00
|
|
|
try:
|
|
|
|
|
prefix, raw = raw[1:].split(" ", 1)
|
|
|
|
|
except ValueError:
|
|
|
|
|
prefix = raw[1:]
|
|
|
|
|
raw = ""
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
|
|
|
|
|
if " :" in raw:
|
|
|
|
|
raw, trailing = raw.split(" :", 1)
|
|
|
|
|
|
|
|
|
|
parts = raw.split()
|
|
|
|
|
command = parts[0] if parts else ""
|
|
|
|
|
params = parts[1:] if len(parts) > 1 else []
|
|
|
|
|
if trailing:
|
|
|
|
|
params.append(trailing)
|
|
|
|
|
|
|
|
|
|
return {"prefix": prefix, "command": command, "params": params}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_nick(prefix: str) -> str:
|
|
|
|
|
"""Extract nickname from IRC prefix (nick!user@host)."""
|
|
|
|
|
return prefix.split("!")[0] if "!" in prefix else prefix
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# IRC Adapter
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class IRCAdapter(BasePlatformAdapter):
|
|
|
|
|
"""Async IRC adapter implementing the BasePlatformAdapter interface.
|
|
|
|
|
|
|
|
|
|
This class is instantiated by the adapter_factory passed to
|
|
|
|
|
register_platform().
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, config, **kwargs):
|
|
|
|
|
platform = Platform("irc")
|
|
|
|
|
super().__init__(config=config, platform=platform)
|
|
|
|
|
|
|
|
|
|
extra = getattr(config, "extra", {}) or {}
|
|
|
|
|
|
|
|
|
|
# Connection settings (env vars override config.yaml)
|
|
|
|
|
self.server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
2026-06-07 06:14:24 -07:00
|
|
|
try:
|
|
|
|
|
self.port = int(os.getenv("IRC_PORT") or extra.get("port", 6697))
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
self.port = 6697
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
|
|
|
|
|
self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
|
|
|
|
self.use_tls = (
|
2026-05-17 02:29:41 -07:00
|
|
|
os.getenv("IRC_USE_TLS", "").lower() in {"1", "true", "yes"}
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
if os.getenv("IRC_USE_TLS")
|
|
|
|
|
else extra.get("use_tls", True)
|
|
|
|
|
)
|
|
|
|
|
self.server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
|
|
|
|
|
self.nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
|
|
|
|
|
|
|
|
|
|
# Auth
|
|
|
|
|
self.allowed_users: list = extra.get("allowed_users", [])
|
2026-04-20 18:52:15 -04:00
|
|
|
# IRC nicks are case-insensitive — normalise for lookups
|
|
|
|
|
self._allowed_users_lower: set = {u.lower() for u in self.allowed_users if isinstance(u, str)}
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
|
|
|
|
|
# IRC limits
|
2026-04-20 18:52:15 -04:00
|
|
|
max_msg = extra.get("max_message_length")
|
|
|
|
|
if max_msg is None:
|
|
|
|
|
try:
|
|
|
|
|
from gateway.platform_registry import platform_registry
|
|
|
|
|
entry = platform_registry.get("irc")
|
|
|
|
|
if entry and entry.max_message_length:
|
|
|
|
|
max_msg = entry.max_message_length
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.max_message_length = int(max_msg or 450)
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
|
|
|
|
|
# Runtime state
|
|
|
|
|
self._reader: Optional[asyncio.StreamReader] = None
|
|
|
|
|
self._writer: Optional[asyncio.StreamWriter] = None
|
|
|
|
|
self._recv_task: Optional[asyncio.Task] = None
|
|
|
|
|
self._current_nick = self.nickname
|
|
|
|
|
self._registered = False # IRC registration complete
|
|
|
|
|
self._registration_event = asyncio.Event()
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def name(self) -> str:
|
|
|
|
|
return "IRC"
|
|
|
|
|
|
|
|
|
|
# ── Connection lifecycle ──────────────────────────────────────────────
|
|
|
|
|
|
2026-06-25 21:19:44 -07:00
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
"""Connect to the IRC server, register, and join the channel."""
|
|
|
|
|
if not self.server or not self.channel:
|
|
|
|
|
logger.error("IRC: server and channel must be configured")
|
|
|
|
|
self._set_fatal_error(
|
|
|
|
|
"config_missing",
|
|
|
|
|
"IRC_SERVER and IRC_CHANNEL must be set",
|
|
|
|
|
retryable=False,
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
2026-04-11 19:27:04 -07:00
|
|
|
# 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)
|
|
|
|
|
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
try:
|
|
|
|
|
ssl_ctx = None
|
|
|
|
|
if self.use_tls:
|
|
|
|
|
ssl_ctx = ssl.create_default_context()
|
|
|
|
|
|
|
|
|
|
self._reader, self._writer = await asyncio.wait_for(
|
|
|
|
|
asyncio.open_connection(self.server, self.port, ssl=ssl_ctx),
|
|
|
|
|
timeout=30.0,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("IRC: failed to connect to %s:%s — %s", self.server, self.port, e)
|
|
|
|
|
self._set_fatal_error("connect_failed", str(e), retryable=True)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# IRC registration sequence
|
|
|
|
|
if self.server_password:
|
|
|
|
|
await self._send_raw(f"PASS {self.server_password}")
|
|
|
|
|
await self._send_raw(f"NICK {self.nickname}")
|
|
|
|
|
await self._send_raw(f"USER {self.nickname} 0 * :Hermes Agent")
|
|
|
|
|
|
|
|
|
|
# Start receive loop
|
|
|
|
|
self._recv_task = asyncio.create_task(self._receive_loop())
|
|
|
|
|
|
|
|
|
|
# Wait for registration (001 RPL_WELCOME) with timeout
|
|
|
|
|
try:
|
|
|
|
|
await asyncio.wait_for(self._registration_event.wait(), timeout=30.0)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
logger.error("IRC: registration timed out")
|
|
|
|
|
await self.disconnect()
|
|
|
|
|
self._set_fatal_error("registration_timeout", "IRC server did not send RPL_WELCOME", retryable=True)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# NickServ identification
|
|
|
|
|
if self.nickserv_password:
|
|
|
|
|
await self._send_raw(f"PRIVMSG NickServ :IDENTIFY {self.nickserv_password}")
|
|
|
|
|
await asyncio.sleep(2) # Give NickServ time to process
|
|
|
|
|
|
|
|
|
|
# Join channel
|
|
|
|
|
await self._send_raw(f"JOIN {self.channel}")
|
|
|
|
|
|
|
|
|
|
self._mark_connected()
|
|
|
|
|
logger.info("IRC: connected to %s:%s as %s, joined %s", self.server, self.port, self._current_nick, self.channel)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def disconnect(self) -> None:
|
|
|
|
|
"""Quit and close the connection."""
|
2026-04-11 19:27:04 -07:00
|
|
|
# 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
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
self._mark_disconnected()
|
|
|
|
|
if self._writer and not self._writer.is_closing():
|
|
|
|
|
try:
|
|
|
|
|
await self._send_raw("QUIT :Hermes Agent shutting down")
|
|
|
|
|
await asyncio.sleep(0.5)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
self._writer.close()
|
|
|
|
|
await self._writer.wait_closed()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
if self._recv_task and not self._recv_task.done():
|
|
|
|
|
self._recv_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await self._recv_task
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
self._reader = None
|
|
|
|
|
self._writer = None
|
|
|
|
|
self._registered = False
|
|
|
|
|
self._registration_event.clear()
|
|
|
|
|
|
|
|
|
|
# ── Sending ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async def send(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
content: str,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
|
|
|
):
|
|
|
|
|
if not self._writer or self._writer.is_closing():
|
|
|
|
|
return SendResult(success=False, error="Not connected")
|
|
|
|
|
|
|
|
|
|
target = chat_id # channel name or nick for DMs
|
|
|
|
|
lines = self._split_message(content, target)
|
|
|
|
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
try:
|
|
|
|
|
await self._send_raw(f"PRIVMSG {target} :{line}")
|
|
|
|
|
# Basic rate limiting to avoid excess flood
|
|
|
|
|
await asyncio.sleep(0.3)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
|
|
|
|
|
|
return SendResult(success=True, message_id=str(int(time.time() * 1000)))
|
|
|
|
|
|
|
|
|
|
async def send_typing(self, chat_id: str, metadata=None) -> None:
|
|
|
|
|
"""IRC has no typing indicator — no-op."""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
|
|
|
is_channel = chat_id.startswith("#") or chat_id.startswith("&")
|
|
|
|
|
return {
|
|
|
|
|
"name": chat_id,
|
|
|
|
|
"type": "group" if is_channel else "dm",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ── Message splitting ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _split_message(self, content: str, target: str) -> List[str]:
|
|
|
|
|
"""Split a long message into IRC-safe chunks.
|
|
|
|
|
|
|
|
|
|
IRC has a ~512 byte line limit. After accounting for protocol
|
|
|
|
|
overhead (``PRIVMSG <target> :``), we split content into chunks.
|
|
|
|
|
"""
|
|
|
|
|
# Strip markdown formatting that doesn't render in IRC
|
|
|
|
|
content = self._strip_markdown(content)
|
|
|
|
|
|
|
|
|
|
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2 # +2 for \r\n
|
|
|
|
|
max_bytes = 510 - overhead
|
2026-04-20 18:52:15 -04:00
|
|
|
user_limit = self.max_message_length
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
|
|
|
|
|
lines: List[str] = []
|
|
|
|
|
for paragraph in content.split("\n"):
|
|
|
|
|
if not paragraph.strip():
|
|
|
|
|
continue
|
2026-04-20 18:52:15 -04:00
|
|
|
while True:
|
|
|
|
|
para_bytes = paragraph.encode("utf-8")
|
|
|
|
|
limit = min(user_limit, max_bytes)
|
|
|
|
|
if len(para_bytes) <= limit:
|
|
|
|
|
if paragraph.strip():
|
|
|
|
|
lines.append(paragraph)
|
|
|
|
|
break
|
|
|
|
|
# Binary search for a safe character boundary <= limit
|
|
|
|
|
low, high = 1, len(paragraph)
|
|
|
|
|
best = 0
|
|
|
|
|
while low <= high:
|
|
|
|
|
mid = (low + high) // 2
|
|
|
|
|
if len(paragraph[:mid].encode("utf-8")) <= limit:
|
|
|
|
|
best = mid
|
|
|
|
|
low = mid + 1
|
|
|
|
|
else:
|
|
|
|
|
high = mid - 1
|
|
|
|
|
split_at = best
|
|
|
|
|
# Prefer a space boundary
|
|
|
|
|
space = paragraph.rfind(" ", 0, split_at)
|
|
|
|
|
if space > split_at // 3:
|
|
|
|
|
split_at = space
|
|
|
|
|
lines.append(paragraph[:split_at].rstrip())
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
paragraph = paragraph[split_at:].lstrip()
|
|
|
|
|
|
|
|
|
|
return lines if lines else [""]
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _strip_markdown(text: str) -> str:
|
|
|
|
|
"""Convert basic markdown to plain text for IRC."""
|
|
|
|
|
# Bold: **text** or __text__ → text
|
|
|
|
|
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
|
|
|
|
text = re.sub(r"__(.+?)__", r"\1", text)
|
|
|
|
|
# Italic: *text* or _text_ → text
|
|
|
|
|
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
|
|
|
|
text = re.sub(r"(?<!\w)_(.+?)_(?!\w)", r"\1", text)
|
|
|
|
|
# Inline code: `text` → text
|
|
|
|
|
text = re.sub(r"`(.+?)`", r"\1", text)
|
|
|
|
|
# Code blocks: ```...``` → content
|
|
|
|
|
text = re.sub(r"```\w*\n?", "", text)
|
|
|
|
|
# Images:  → url (must come BEFORE links)
|
|
|
|
|
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", text)
|
|
|
|
|
# Links: [text](url) → text (url)
|
|
|
|
|
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
# ── Raw IRC I/O ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async def _send_raw(self, line: str) -> None:
|
|
|
|
|
"""Send a raw IRC protocol line."""
|
|
|
|
|
if not self._writer or self._writer.is_closing():
|
|
|
|
|
return
|
|
|
|
|
encoded = (line + "\r\n").encode("utf-8")
|
|
|
|
|
self._writer.write(encoded)
|
|
|
|
|
await self._writer.drain()
|
|
|
|
|
|
|
|
|
|
async def _receive_loop(self) -> None:
|
|
|
|
|
"""Main receive loop — reads lines and dispatches them."""
|
|
|
|
|
buffer = b""
|
|
|
|
|
try:
|
|
|
|
|
while self._reader and not self._reader.at_eof():
|
|
|
|
|
data = await self._reader.read(4096)
|
|
|
|
|
if not data:
|
|
|
|
|
break
|
|
|
|
|
buffer += data
|
|
|
|
|
while b"\r\n" in buffer:
|
|
|
|
|
line, buffer = buffer.split(b"\r\n", 1)
|
|
|
|
|
try:
|
|
|
|
|
decoded = line.decode("utf-8", errors="replace")
|
|
|
|
|
await self._handle_line(decoded)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("IRC: error handling line: %s", e)
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("IRC: receive loop error: %s", e)
|
|
|
|
|
finally:
|
|
|
|
|
if self.is_connected:
|
|
|
|
|
logger.warning("IRC: connection lost, marking disconnected")
|
|
|
|
|
self._set_fatal_error("connection_lost", "IRC connection closed unexpectedly", retryable=True)
|
|
|
|
|
await self._notify_fatal_error()
|
|
|
|
|
|
|
|
|
|
async def _handle_line(self, raw: str) -> None:
|
|
|
|
|
"""Dispatch a single IRC protocol line."""
|
|
|
|
|
msg = _parse_irc_message(raw)
|
|
|
|
|
command = msg["command"]
|
|
|
|
|
params = msg["params"]
|
|
|
|
|
|
|
|
|
|
# PING/PONG keepalive
|
|
|
|
|
if command == "PING":
|
|
|
|
|
payload = params[0] if params else ""
|
|
|
|
|
await self._send_raw(f"PONG :{payload}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# RPL_WELCOME (001) — registration complete
|
|
|
|
|
if command == "001":
|
|
|
|
|
self._registered = True
|
|
|
|
|
self._registration_event.set()
|
|
|
|
|
if params:
|
|
|
|
|
# Server may confirm our nick in the first param
|
|
|
|
|
self._current_nick = params[0]
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# ERR_NICKNAMEINUSE (433) — nick collision during registration
|
|
|
|
|
if command == "433":
|
2026-04-20 18:52:15 -04:00
|
|
|
# Retry with incrementing suffix: hermes_, hermes_1, hermes_2...
|
|
|
|
|
base = self.nickname.rstrip("_0123456789")
|
|
|
|
|
suffix_match = re.search(r"_(\d+)$", self._current_nick)
|
|
|
|
|
if suffix_match:
|
|
|
|
|
next_num = int(suffix_match.group(1)) + 1
|
|
|
|
|
self._current_nick = f"{base}_{next_num}"
|
|
|
|
|
elif self._current_nick == self.nickname:
|
|
|
|
|
self._current_nick = self.nickname + "_"
|
|
|
|
|
else:
|
|
|
|
|
self._current_nick = self.nickname + "_1"
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
await self._send_raw(f"NICK {self._current_nick}")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# PRIVMSG — incoming message (channel or DM)
|
|
|
|
|
if command == "PRIVMSG" and len(params) >= 2:
|
|
|
|
|
sender_nick = _extract_nick(msg["prefix"])
|
|
|
|
|
target = params[0]
|
|
|
|
|
text = params[1]
|
|
|
|
|
|
|
|
|
|
# Ignore our own messages
|
|
|
|
|
if sender_nick.lower() == self._current_nick.lower():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# CTCP ACTION (/me) — convert to text
|
|
|
|
|
if text.startswith("\x01ACTION ") and text.endswith("\x01"):
|
|
|
|
|
text = f"* {sender_nick} {text[8:-1]}"
|
|
|
|
|
|
|
|
|
|
# Ignore other CTCP
|
|
|
|
|
if text.startswith("\x01"):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Determine if this is a channel message or DM
|
|
|
|
|
is_channel = target.startswith("#") or target.startswith("&")
|
|
|
|
|
chat_id = target if is_channel else sender_nick
|
|
|
|
|
chat_type = "group" if is_channel else "dm"
|
|
|
|
|
|
|
|
|
|
# In channels, only respond if addressed (nick: or nick,)
|
|
|
|
|
if is_channel:
|
|
|
|
|
addressed = False
|
|
|
|
|
for prefix in (f"{self._current_nick}:", f"{self._current_nick},",
|
|
|
|
|
f"{self._current_nick} "):
|
|
|
|
|
if text.lower().startswith(prefix.lower()):
|
|
|
|
|
text = text[len(prefix):].strip()
|
|
|
|
|
addressed = True
|
|
|
|
|
break
|
|
|
|
|
if not addressed:
|
|
|
|
|
return # Ignore unaddressed channel messages
|
|
|
|
|
|
2026-04-20 18:52:15 -04:00
|
|
|
# Auth check (case-insensitive)
|
|
|
|
|
if self._allowed_users_lower and sender_nick.lower() not in self._allowed_users_lower:
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
logger.debug("IRC: ignoring message from unauthorized user %s", sender_nick)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await self._dispatch_message(
|
|
|
|
|
text=text,
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
chat_type=chat_type,
|
|
|
|
|
user_id=sender_nick,
|
|
|
|
|
user_name=sender_nick,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# NICK — track our own nick changes
|
|
|
|
|
if command == "NICK" and _extract_nick(msg["prefix"]).lower() == self._current_nick.lower():
|
|
|
|
|
if params:
|
|
|
|
|
self._current_nick = params[0]
|
|
|
|
|
|
|
|
|
|
async def _dispatch_message(
|
|
|
|
|
self,
|
|
|
|
|
text: str,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
chat_type: str,
|
|
|
|
|
user_id: str,
|
|
|
|
|
user_name: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Build a MessageEvent and hand it to the base class handler."""
|
|
|
|
|
if not self._message_handler:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
source = self.build_source(
|
|
|
|
|
chat_id=chat_id,
|
|
|
|
|
chat_name=chat_id,
|
|
|
|
|
chat_type=chat_type,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
user_name=user_name,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
event = MessageEvent(
|
|
|
|
|
text=text,
|
|
|
|
|
message_type=MessageType.TEXT,
|
|
|
|
|
source=source,
|
|
|
|
|
message_id=str(int(time.time() * 1000)),
|
|
|
|
|
timestamp=__import__("datetime").datetime.now(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await self.handle_message(event)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Plugin registration
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def check_requirements() -> bool:
|
|
|
|
|
"""Check if IRC is configured.
|
|
|
|
|
|
|
|
|
|
Only requires the server and channel — no external pip packages needed.
|
|
|
|
|
"""
|
|
|
|
|
server = os.getenv("IRC_SERVER", "")
|
|
|
|
|
channel = os.getenv("IRC_CHANNEL", "")
|
|
|
|
|
# Also accept config.yaml-only configuration (no env vars).
|
|
|
|
|
# The gateway passes PlatformConfig; we just check env for the
|
|
|
|
|
# hermes setup / requirements check path.
|
|
|
|
|
return bool(server and channel)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_config(config) -> bool:
|
|
|
|
|
"""Validate that the platform config has enough info to connect."""
|
|
|
|
|
extra = getattr(config, "extra", {}) or {}
|
|
|
|
|
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
|
|
|
|
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
|
|
|
|
return bool(server and channel)
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 18:52:15 -04:00
|
|
|
def interactive_setup() -> None:
|
|
|
|
|
"""Interactive `hermes gateway setup` flow for the IRC platform.
|
|
|
|
|
|
|
|
|
|
Lazy-imports ``hermes_cli.setup`` helpers so the plugin stays importable
|
|
|
|
|
in non-CLI contexts (gateway runtime, tests).
|
|
|
|
|
"""
|
|
|
|
|
from hermes_cli.setup import (
|
|
|
|
|
prompt,
|
|
|
|
|
prompt_yes_no,
|
|
|
|
|
save_env_value,
|
|
|
|
|
get_env_value,
|
|
|
|
|
print_header,
|
|
|
|
|
print_info,
|
|
|
|
|
print_warning,
|
|
|
|
|
print_success,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
print_header("IRC")
|
|
|
|
|
existing_server = get_env_value("IRC_SERVER")
|
|
|
|
|
if existing_server:
|
|
|
|
|
print_info(f"IRC: already configured (server: {existing_server})")
|
|
|
|
|
if not prompt_yes_no("Reconfigure IRC?", False):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
print_info("Connect Hermes to an IRC network. Uses Python stdlib — no extra packages needed.")
|
|
|
|
|
print_info(" Works with Libera.Chat, OFTC, your own ZNC/InspIRCd, etc.")
|
|
|
|
|
print()
|
|
|
|
|
|
|
|
|
|
server = prompt("IRC server hostname (e.g. irc.libera.chat)", default=existing_server or "")
|
|
|
|
|
if not server:
|
|
|
|
|
print_warning("Server is required — skipping IRC setup")
|
|
|
|
|
return
|
|
|
|
|
save_env_value("IRC_SERVER", server.strip())
|
|
|
|
|
|
|
|
|
|
use_tls = prompt_yes_no("Use TLS (recommended)?", True)
|
|
|
|
|
save_env_value("IRC_USE_TLS", "true" if use_tls else "false")
|
|
|
|
|
|
|
|
|
|
default_port = "6697" if use_tls else "6667"
|
|
|
|
|
port = prompt(f"Port (default {default_port})", default=get_env_value("IRC_PORT") or "")
|
|
|
|
|
if port:
|
|
|
|
|
try:
|
|
|
|
|
save_env_value("IRC_PORT", str(int(port)))
|
|
|
|
|
except ValueError:
|
|
|
|
|
print_warning(f"Invalid port — using default {default_port}")
|
|
|
|
|
elif get_env_value("IRC_PORT"):
|
|
|
|
|
# User cleared the prompt; drop the override so the default applies.
|
|
|
|
|
save_env_value("IRC_PORT", "")
|
|
|
|
|
|
|
|
|
|
nickname = prompt(
|
|
|
|
|
"Bot nickname (e.g. hermes-bot)",
|
|
|
|
|
default=get_env_value("IRC_NICKNAME") or "",
|
|
|
|
|
)
|
|
|
|
|
if not nickname:
|
|
|
|
|
print_warning("Nickname is required — skipping IRC setup")
|
|
|
|
|
return
|
|
|
|
|
save_env_value("IRC_NICKNAME", nickname.strip())
|
|
|
|
|
|
|
|
|
|
channel = prompt(
|
|
|
|
|
"Channel to join (e.g. #hermes — comma-separate for multiple)",
|
|
|
|
|
default=get_env_value("IRC_CHANNEL") or "",
|
|
|
|
|
)
|
|
|
|
|
if not channel:
|
|
|
|
|
print_warning("Channel is required — skipping IRC setup")
|
|
|
|
|
return
|
|
|
|
|
save_env_value("IRC_CHANNEL", channel.strip())
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
print_info("🔑 Optional authentication")
|
|
|
|
|
print_info(" Leave blank to skip.")
|
|
|
|
|
if prompt_yes_no("Configure a server password (PASS command)?", False):
|
|
|
|
|
server_password = prompt("Server password", password=True)
|
|
|
|
|
if server_password:
|
|
|
|
|
save_env_value("IRC_SERVER_PASSWORD", server_password)
|
|
|
|
|
|
|
|
|
|
if prompt_yes_no("Identify with NickServ on connect?", False):
|
|
|
|
|
nickserv = prompt("NickServ password", password=True)
|
|
|
|
|
if nickserv:
|
|
|
|
|
save_env_value("IRC_NICKSERV_PASSWORD", nickserv)
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
print_info("🔒 Access control: restrict who can message the bot")
|
|
|
|
|
print_info(" IRC nicks are not authenticated — anyone can claim any nick.")
|
|
|
|
|
print_info(" For public channels, pair with NickServ-only mode on your network")
|
|
|
|
|
print_info(" if you want stronger identity guarantees.")
|
|
|
|
|
allow_all = prompt_yes_no("Allow all users in the channel to talk to the bot?", False)
|
|
|
|
|
if allow_all:
|
|
|
|
|
save_env_value("IRC_ALLOW_ALL_USERS", "true")
|
|
|
|
|
save_env_value("IRC_ALLOWED_USERS", "")
|
|
|
|
|
print_warning("⚠️ Open access — any nick in the channel can command the bot.")
|
|
|
|
|
else:
|
|
|
|
|
save_env_value("IRC_ALLOW_ALL_USERS", "false")
|
|
|
|
|
allowed = prompt(
|
|
|
|
|
"Allowed nicks (comma-separated, leave empty to deny everyone)",
|
|
|
|
|
default=get_env_value("IRC_ALLOWED_USERS") or "",
|
|
|
|
|
)
|
|
|
|
|
if allowed:
|
|
|
|
|
save_env_value("IRC_ALLOWED_USERS", allowed.replace(" ", ""))
|
|
|
|
|
print_success("Allowlist configured")
|
|
|
|
|
else:
|
|
|
|
|
save_env_value("IRC_ALLOWED_USERS", "")
|
|
|
|
|
print_info("No nicks allowed — the bot will ignore all messages until you add nicks.")
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
print_success("IRC configuration saved to ~/.hermes/.env")
|
|
|
|
|
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_connected(config) -> bool:
|
|
|
|
|
"""Check whether IRC is configured (env or config.yaml)."""
|
|
|
|
|
extra = getattr(config, "extra", {}) or {}
|
|
|
|
|
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
|
|
|
|
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
|
|
|
|
return bool(server and channel)
|
|
|
|
|
|
|
|
|
|
|
refactor(plugins/platforms): migrate IRC + Teams to new env_enablement + cron_deliver hooks
Adopt the generic platform-plugin hooks landed in the preceding commit
so IRC and Teams get env-only config detection and cron home-channel
delivery without living in cron/scheduler.py's hardcoded sets.
IRC (plugins/platforms/irc/):
- adapter.py: new _env_enablement() seeds server, channel, port,
nickname, use_tls, server_password, nickserv_password, and a
home_channel dict into PlatformConfig on env-only setups.
IRC_HOME_CHANNEL defaults to IRC_CHANNEL so deliver=irc cron jobs
route to the joined channel by default.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='IRC_HOME_CHANNEL'.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every IRC env var. Hardcoded IRC entries
in hermes_cli/config.py still win (back-compat), but the plugin now
carries its own metadata.
Teams (plugins/platforms/teams/):
- adapter.py: new _env_enablement() seeds client_id, client_secret,
tenant_id, port, and home_channel into PlatformConfig. Closes the
long-standing gap where TEAMS_HOME_CHANNEL was documented but never
wired up.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='TEAMS_HOME_CHANNEL' — deliver=teams cron
jobs now work.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every Teams env var. Surfaces them in
'hermes config' UI for the first time (Teams had no OPTIONAL_ENV_VARS
entries before this).
Zero behavior change for existing users: env_enablement_fn is only
called when env vars are set, and the registry's config-first-env-fallback
path in validate_config / is_connected is unchanged.
2026-05-07 06:47:25 -07:00
|
|
|
def _env_enablement() -> dict | None:
|
|
|
|
|
"""Seed ``PlatformConfig.extra`` from env vars during gateway config load.
|
|
|
|
|
|
|
|
|
|
Called by the platform registry's env-enablement hook (landed in the
|
|
|
|
|
generic-plugin-interface migration) BEFORE adapter construction, so
|
|
|
|
|
``gateway status`` and ``get_connected_platforms()`` reflect env-only
|
|
|
|
|
configuration without instantiating the IRC client. Returns ``None``
|
|
|
|
|
when IRC isn't minimally configured; the caller skips auto-enabling.
|
|
|
|
|
|
|
|
|
|
The special ``home_channel`` key in the returned dict is handled by
|
|
|
|
|
the core hook — it becomes a proper ``HomeChannel`` dataclass on the
|
|
|
|
|
``PlatformConfig`` rather than being merged into ``extra``.
|
|
|
|
|
"""
|
|
|
|
|
server = os.getenv("IRC_SERVER", "").strip()
|
|
|
|
|
channel = os.getenv("IRC_CHANNEL", "").strip()
|
|
|
|
|
if not (server and channel):
|
|
|
|
|
return None
|
|
|
|
|
seed: dict = {
|
|
|
|
|
"server": server,
|
|
|
|
|
"channel": channel,
|
|
|
|
|
}
|
|
|
|
|
port = os.getenv("IRC_PORT", "").strip()
|
|
|
|
|
if port:
|
|
|
|
|
try:
|
|
|
|
|
seed["port"] = int(port)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
nickname = os.getenv("IRC_NICKNAME", "").strip()
|
|
|
|
|
if nickname:
|
|
|
|
|
seed["nickname"] = nickname
|
|
|
|
|
use_tls = os.getenv("IRC_USE_TLS", "").strip().lower()
|
|
|
|
|
if use_tls:
|
2026-05-17 02:29:41 -07:00
|
|
|
seed["use_tls"] = use_tls in {"1", "true", "yes"}
|
refactor(plugins/platforms): migrate IRC + Teams to new env_enablement + cron_deliver hooks
Adopt the generic platform-plugin hooks landed in the preceding commit
so IRC and Teams get env-only config detection and cron home-channel
delivery without living in cron/scheduler.py's hardcoded sets.
IRC (plugins/platforms/irc/):
- adapter.py: new _env_enablement() seeds server, channel, port,
nickname, use_tls, server_password, nickserv_password, and a
home_channel dict into PlatformConfig on env-only setups.
IRC_HOME_CHANNEL defaults to IRC_CHANNEL so deliver=irc cron jobs
route to the joined channel by default.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='IRC_HOME_CHANNEL'.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every IRC env var. Hardcoded IRC entries
in hermes_cli/config.py still win (back-compat), but the plugin now
carries its own metadata.
Teams (plugins/platforms/teams/):
- adapter.py: new _env_enablement() seeds client_id, client_secret,
tenant_id, port, and home_channel into PlatformConfig. Closes the
long-standing gap where TEAMS_HOME_CHANNEL was documented but never
wired up.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='TEAMS_HOME_CHANNEL' — deliver=teams cron
jobs now work.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every Teams env var. Surfaces them in
'hermes config' UI for the first time (Teams had no OPTIONAL_ENV_VARS
entries before this).
Zero behavior change for existing users: env_enablement_fn is only
called when env vars are set, and the registry's config-first-env-fallback
path in validate_config / is_connected is unchanged.
2026-05-07 06:47:25 -07:00
|
|
|
# Passwords live in PlatformConfig.extra as well for back-compat with
|
|
|
|
|
# existing config.yaml users; env-reads at construct time still win.
|
|
|
|
|
if os.getenv("IRC_SERVER_PASSWORD"):
|
|
|
|
|
seed["server_password"] = os.getenv("IRC_SERVER_PASSWORD")
|
|
|
|
|
if os.getenv("IRC_NICKSERV_PASSWORD"):
|
|
|
|
|
seed["nickserv_password"] = os.getenv("IRC_NICKSERV_PASSWORD")
|
|
|
|
|
# Optional home-channel (usually the same as IRC_CHANNEL, but can be a
|
|
|
|
|
# dedicated reports channel). Defaults to IRC_CHANNEL so cron jobs
|
|
|
|
|
# with ``deliver=irc`` have a sensible target without extra config.
|
|
|
|
|
home = os.getenv("IRC_HOME_CHANNEL") or channel
|
|
|
|
|
if home:
|
|
|
|
|
seed["home_channel"] = {
|
|
|
|
|
"chat_id": home,
|
|
|
|
|
"name": os.getenv("IRC_HOME_CHANNEL_NAME", home),
|
|
|
|
|
}
|
|
|
|
|
return seed
|
|
|
|
|
|
|
|
|
|
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
def _strip_irc_control_chars(text: str) -> str:
|
|
|
|
|
"""Strip IRC line terminators and the NUL byte from ``text``.
|
|
|
|
|
|
|
|
|
|
IRC commands are CRLF-delimited; a bare ``\\r`` or ``\\n`` in user
|
|
|
|
|
content lets an attacker inject arbitrary IRC commands (CTCP, JOIN,
|
|
|
|
|
KICK). ``\\x00`` is a protocol-illegal byte. Everything else is
|
|
|
|
|
valid in PRIVMSG payloads.
|
|
|
|
|
"""
|
|
|
|
|
return text.replace("\r", " ").replace("\n", " ").replace("\x00", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_irc_channel(target: str) -> bool:
|
|
|
|
|
return bool(target) and target[0] in "#&+!"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _standalone_send(
|
|
|
|
|
pconfig,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
message: str,
|
|
|
|
|
*,
|
|
|
|
|
thread_id: Optional[str] = None,
|
|
|
|
|
media_files: Optional[List[str]] = None,
|
|
|
|
|
force_document: bool = False,
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""Open an ephemeral IRC connection, send a PRIVMSG, and quit.
|
|
|
|
|
|
|
|
|
|
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
|
|
|
|
runner is not in this process (e.g. ``hermes cron`` running as a
|
|
|
|
|
separate process from ``hermes gateway``). Without this hook,
|
|
|
|
|
``deliver=irc`` cron jobs fail with ``No live adapter for platform``.
|
|
|
|
|
|
|
|
|
|
The standalone client uses a distinct nick suffix (``-cron``) so it
|
|
|
|
|
does not collide with the long-running gateway adapter that may already
|
|
|
|
|
be holding the configured nickname on the same network. When the
|
|
|
|
|
target is a channel, the client JOINs it before sending PRIVMSG so
|
|
|
|
|
networks with the default ``+n`` (no external messages) channel mode
|
|
|
|
|
accept the delivery.
|
|
|
|
|
|
|
|
|
|
``thread_id`` and ``media_files`` are accepted for signature parity but
|
|
|
|
|
are not meaningful on IRC: IRC has no native thread or attachment
|
|
|
|
|
primitive.
|
|
|
|
|
"""
|
|
|
|
|
extra = getattr(pconfig, "extra", {}) or {}
|
|
|
|
|
server = os.getenv("IRC_SERVER") or extra.get("server", "")
|
|
|
|
|
channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "")
|
|
|
|
|
if not server or not channel:
|
|
|
|
|
return {"error": "IRC standalone send: IRC_SERVER and IRC_CHANNEL must be configured"}
|
|
|
|
|
|
|
|
|
|
port_value = os.getenv("IRC_PORT") or extra.get("port", 6697)
|
|
|
|
|
try:
|
|
|
|
|
port = int(port_value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return {"error": f"IRC standalone send: invalid port {port_value!r}"}
|
|
|
|
|
|
|
|
|
|
nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot")
|
|
|
|
|
use_tls_env = os.getenv("IRC_USE_TLS")
|
|
|
|
|
if use_tls_env is not None:
|
2026-05-17 02:29:41 -07:00
|
|
|
use_tls = use_tls_env.lower() in {"1", "true", "yes"}
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
else:
|
|
|
|
|
use_tls = bool(extra.get("use_tls", True))
|
|
|
|
|
|
|
|
|
|
server_password = os.getenv("IRC_SERVER_PASSWORD") or extra.get("server_password", "")
|
|
|
|
|
nickserv_password = os.getenv("IRC_NICKSERV_PASSWORD") or extra.get("nickserv_password", "")
|
|
|
|
|
|
|
|
|
|
# Reject control characters in chat_id to block IRC command injection.
|
|
|
|
|
raw_target = chat_id or channel
|
|
|
|
|
if any(ch in raw_target for ch in ("\r", "\n", "\x00", " ")):
|
|
|
|
|
return {"error": "IRC standalone send: chat_id contains illegal IRC characters"}
|
|
|
|
|
target = raw_target
|
|
|
|
|
|
|
|
|
|
# Distinct nick prevents NICK collision with a live gateway adapter
|
|
|
|
|
# that may already be holding the configured nickname. Cap to 24 chars
|
|
|
|
|
# so subsequent collision retries do not overflow the 30-char NICKLEN
|
|
|
|
|
# most networks enforce.
|
|
|
|
|
nick_base = nickname.rstrip("_0123456789-")[:24] or "hermes-bot"
|
|
|
|
|
standalone_nick = f"{nick_base}-cron"[:30]
|
|
|
|
|
plain = IRCAdapter._strip_markdown(message)
|
|
|
|
|
|
|
|
|
|
ssl_ctx = ssl.create_default_context() if use_tls else None
|
|
|
|
|
try:
|
|
|
|
|
reader, writer = await asyncio.wait_for(
|
|
|
|
|
asyncio.open_connection(server, port, ssl=ssl_ctx),
|
|
|
|
|
timeout=15.0,
|
|
|
|
|
)
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {"error": f"IRC standalone connect failed: {e}"}
|
|
|
|
|
|
|
|
|
|
async def _raw(line: str) -> None:
|
|
|
|
|
writer.write((line + "\r\n").encode("utf-8"))
|
|
|
|
|
await writer.drain()
|
|
|
|
|
|
|
|
|
|
nick_attempts = 0
|
|
|
|
|
max_nick_attempts = 5
|
|
|
|
|
try:
|
|
|
|
|
if server_password:
|
|
|
|
|
await _raw(f"PASS {_strip_irc_control_chars(server_password)}")
|
|
|
|
|
await _raw(f"NICK {standalone_nick}")
|
|
|
|
|
await _raw(f"USER {standalone_nick} 0 * :Hermes Agent (cron)")
|
|
|
|
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
deadline = loop.time() + 15.0
|
|
|
|
|
registered = False
|
|
|
|
|
while not registered:
|
|
|
|
|
remaining = deadline - loop.time()
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
|
|
|
|
|
try:
|
|
|
|
|
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
return {"error": "IRC standalone send: registration timeout (no RPL_WELCOME)"}
|
|
|
|
|
except asyncio.IncompleteReadError:
|
|
|
|
|
return {"error": "IRC standalone send: server closed connection during registration"}
|
|
|
|
|
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
|
|
|
msg = _parse_irc_message(decoded)
|
|
|
|
|
cmd = msg["command"]
|
|
|
|
|
if cmd == "PING":
|
|
|
|
|
payload = msg["params"][0] if msg["params"] else ""
|
|
|
|
|
await _raw(f"PONG :{payload}")
|
|
|
|
|
elif cmd == "001":
|
|
|
|
|
registered = True
|
2026-05-17 02:29:41 -07:00
|
|
|
elif cmd in {"432", "433"}:
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
nick_attempts += 1
|
|
|
|
|
if nick_attempts > max_nick_attempts:
|
|
|
|
|
return {"error": "IRC standalone send: too many nick collisions"}
|
|
|
|
|
# Build the next nick from the stable base, not the
|
|
|
|
|
# mutated value, so the suffix stays bounded.
|
|
|
|
|
standalone_nick = f"{nick_base}-cron-{nick_attempts}"[:30]
|
|
|
|
|
await _raw(f"NICK {standalone_nick}")
|
2026-05-17 02:29:41 -07:00
|
|
|
elif cmd in {"464", "465"}:
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
return {"error": f"IRC standalone send: server rejected client ({cmd})"}
|
|
|
|
|
|
|
|
|
|
if nickserv_password:
|
|
|
|
|
await _raw(f"PRIVMSG NickServ :IDENTIFY {_strip_irc_control_chars(nickserv_password)}")
|
|
|
|
|
await asyncio.sleep(2)
|
|
|
|
|
|
|
|
|
|
# JOIN before PRIVMSG. IRC channels with the default ``+n`` mode
|
|
|
|
|
# (no external messages: Libera, OFTC, EFnet, IRCNet, undernet)
|
|
|
|
|
# silently drop PRIVMSG from non-members. Do not JOIN bare nicks
|
|
|
|
|
# (DM target) or server queries.
|
|
|
|
|
if _is_irc_channel(target):
|
|
|
|
|
await _raw(f"JOIN {target}")
|
|
|
|
|
join_deadline = loop.time() + 5.0
|
|
|
|
|
joined = False
|
|
|
|
|
while not joined:
|
|
|
|
|
remaining = join_deadline - loop.time()
|
|
|
|
|
if remaining <= 0:
|
|
|
|
|
# Timed out waiting for a JOIN ack: proceed anyway, the
|
|
|
|
|
# server may still deliver the PRIVMSG depending on mode.
|
|
|
|
|
break
|
|
|
|
|
try:
|
|
|
|
|
raw_line = await asyncio.wait_for(reader.readuntil(b"\r\n"), timeout=remaining)
|
|
|
|
|
except (asyncio.TimeoutError, asyncio.IncompleteReadError):
|
|
|
|
|
break
|
|
|
|
|
decoded = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
|
|
|
jmsg = _parse_irc_message(decoded)
|
|
|
|
|
jcmd = jmsg["command"]
|
|
|
|
|
if jcmd == "PING":
|
|
|
|
|
payload = jmsg["params"][0] if jmsg["params"] else ""
|
|
|
|
|
await _raw(f"PONG :{payload}")
|
2026-05-17 02:29:41 -07:00
|
|
|
elif jcmd in {"366", "JOIN"}:
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
joined = True
|
2026-05-17 02:29:41 -07:00
|
|
|
elif jcmd in {"403", "405", "471", "473", "474", "475"}:
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
return {"error": f"IRC standalone send: JOIN {target} rejected ({jcmd})"}
|
|
|
|
|
|
|
|
|
|
# Bytes-aware per-line splitting so multi-line plain text never
|
|
|
|
|
# exceeds the IRC 510-byte protocol limit. Reuses the same
|
|
|
|
|
# algorithm as IRCAdapter._split_message, with control-character
|
|
|
|
|
# stripping per line to block CRLF injection from message content.
|
|
|
|
|
overhead = len(f"PRIVMSG {target} :".encode("utf-8")) + 2
|
|
|
|
|
max_bytes = 510 - overhead
|
|
|
|
|
sent_any = False
|
|
|
|
|
for paragraph in plain.split("\n"):
|
|
|
|
|
paragraph = _strip_irc_control_chars(paragraph).rstrip()
|
|
|
|
|
if not paragraph:
|
|
|
|
|
continue
|
|
|
|
|
while paragraph:
|
|
|
|
|
encoded = paragraph.encode("utf-8")
|
|
|
|
|
if len(encoded) <= max_bytes:
|
|
|
|
|
await _raw(f"PRIVMSG {target} :{paragraph}")
|
|
|
|
|
await asyncio.sleep(0.3)
|
|
|
|
|
sent_any = True
|
|
|
|
|
break
|
|
|
|
|
# Binary search for largest prefix that fits within max_bytes
|
|
|
|
|
low, high, best = 1, len(paragraph), 0
|
|
|
|
|
while low <= high:
|
|
|
|
|
mid = (low + high) // 2
|
|
|
|
|
if len(paragraph[:mid].encode("utf-8")) <= max_bytes:
|
|
|
|
|
best = mid
|
|
|
|
|
low = mid + 1
|
|
|
|
|
else:
|
|
|
|
|
high = mid - 1
|
|
|
|
|
split_at = best
|
|
|
|
|
space = paragraph.rfind(" ", 0, split_at)
|
|
|
|
|
if space > split_at // 3:
|
|
|
|
|
split_at = space
|
|
|
|
|
await _raw(f"PRIVMSG {target} :{paragraph[:split_at].rstrip()}")
|
|
|
|
|
await asyncio.sleep(0.3)
|
|
|
|
|
sent_any = True
|
|
|
|
|
paragraph = paragraph[split_at:].lstrip()
|
|
|
|
|
|
|
|
|
|
if not sent_any:
|
|
|
|
|
return {"error": "IRC standalone send: empty message after stripping"}
|
|
|
|
|
|
|
|
|
|
await _raw("QUIT :delivered")
|
|
|
|
|
try:
|
|
|
|
|
await asyncio.wait_for(reader.read(1024), timeout=2.0)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
return {"success": True, "message_id": str(int(time.time() * 1000))}
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug("IRC standalone send raised", exc_info=True)
|
|
|
|
|
return {"error": f"IRC standalone send failed: {e}"}
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
writer.close()
|
|
|
|
|
await asyncio.wait_for(writer.wait_closed(), timeout=5.0)
|
|
|
|
|
except (asyncio.TimeoutError, Exception):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
def register(ctx):
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
"""Plugin entry point: called by the Hermes plugin system."""
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
ctx.register_platform(
|
|
|
|
|
name="irc",
|
|
|
|
|
label="IRC",
|
|
|
|
|
adapter_factory=lambda cfg: IRCAdapter(cfg),
|
|
|
|
|
check_fn=check_requirements,
|
|
|
|
|
validate_config=validate_config,
|
2026-04-20 18:52:15 -04:00
|
|
|
is_connected=is_connected,
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
required_env=["IRC_SERVER", "IRC_CHANNEL", "IRC_NICKNAME"],
|
|
|
|
|
install_hint="No extra packages needed (stdlib only)",
|
2026-04-20 18:52:15 -04:00
|
|
|
setup_fn=interactive_setup,
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
refactor(plugins/platforms): migrate IRC + Teams to new env_enablement + cron_deliver hooks
Adopt the generic platform-plugin hooks landed in the preceding commit
so IRC and Teams get env-only config detection and cron home-channel
delivery without living in cron/scheduler.py's hardcoded sets.
IRC (plugins/platforms/irc/):
- adapter.py: new _env_enablement() seeds server, channel, port,
nickname, use_tls, server_password, nickserv_password, and a
home_channel dict into PlatformConfig on env-only setups.
IRC_HOME_CHANNEL defaults to IRC_CHANNEL so deliver=irc cron jobs
route to the joined channel by default.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='IRC_HOME_CHANNEL'.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every IRC env var. Hardcoded IRC entries
in hermes_cli/config.py still win (back-compat), but the plugin now
carries its own metadata.
Teams (plugins/platforms/teams/):
- adapter.py: new _env_enablement() seeds client_id, client_secret,
tenant_id, port, and home_channel into PlatformConfig. Closes the
long-standing gap where TEAMS_HOME_CHANNEL was documented but never
wired up.
- adapter.py: register_platform() gains env_enablement_fn=_env_enablement
and cron_deliver_env_var='TEAMS_HOME_CHANNEL' — deliver=teams cron
jobs now work.
- plugin.yaml: rich requires_env / optional_env with description,
prompt, password, url for every Teams env var. Surfaces them in
'hermes config' UI for the first time (Teams had no OPTIONAL_ENV_VARS
entries before this).
Zero behavior change for existing users: env_enablement_fn is only
called when env vars are set, and the registry's config-first-env-fallback
path in validate_config / is_connected is unchanged.
2026-05-07 06:47:25 -07:00
|
|
|
# server/channel/port/tls + home_channel so env-only setups show
|
|
|
|
|
# up in gateway status without instantiating the adapter.
|
|
|
|
|
env_enablement_fn=_env_enablement,
|
|
|
|
|
# Cron home-channel delivery support. IRC_HOME_CHANNEL defaults to
|
|
|
|
|
# IRC_CHANNEL (see _env_enablement), so cron jobs with
|
|
|
|
|
# deliver=irc route to the joined channel by default.
|
|
|
|
|
cron_deliver_env_var="IRC_HOME_CHANNEL",
|
feat(plugins): add standalone_sender_fn for out-of-process cron delivery
Plugin platforms (IRC, Teams, Google Chat) currently fail with
`No live adapter for platform '<name>'` when a `deliver=<plugin>` cron
job runs in a separate process from the gateway, even though the
platforms are eligible cron targets via `cron_deliver_env_var` (added
in #21306). Built-in platforms (Telegram, Discord, Slack, etc.) use
direct REST helpers in `tools/send_message_tool.py` so cron can deliver
without holding the gateway in the same process; plugin platforms
historically depended on `_gateway_runner_ref()` which returns `None`
out of process.
This change adds an optional `standalone_sender_fn` field to
`PlatformEntry` so plugins can register an ephemeral send path that
opens its own connection, sends, and closes without needing the live
adapter. The dispatch site in `_send_via_adapter` falls through to the
hook when the gateway runner is unavailable, with a descriptive error
when neither path applies. The hook is optional, so existing plugins
are unaffected.
Reference migrations land in the same change for IRC, Teams, and
Google Chat, exercising the hook across stdlib (asyncio + IRC protocol),
Bot Framework OAuth client_credentials, and Google service-account
flows respectively.
Security hardening on the new code paths:
* IRC: control-character stripping on chat_id and message body to
block CRLF command injection; bounded nick-collision retries; JOIN
before PRIVMSG so channels with the default `+n` mode accept the
delivery.
* Teams: TEAMS_SERVICE_URL validated against an allowlist of known
Bot Framework hosts (`smba.trafficmanager.net`,
`smba.infra.gov.teams.microsoft.us`) to block SSRF; chat_id and
tenant_id constrained to the documented Bot Framework character set;
per-request timeouts so a slow STS endpoint cannot starve the
activity POST.
* Google Chat: chat_id and thread_id validated against strict
resource-name regexes; service-account refresh wrapped in
`asyncio.wait_for` so a hung token endpoint cannot stall the
scheduler.
Test coverage: 20 new tests covering happy path, missing-config errors,
network failure modes, and each defensive validation. Existing tests
unchanged. `bash scripts/run_tests.sh tests/tools/test_send_message_tool.py
tests/gateway/test_irc_adapter.py tests/gateway/test_teams.py
tests/gateway/test_google_chat.py` reports 341 passed, 0 regressions.
Documentation: new "Out-of-process cron delivery" section in
website/docs/developer-guide/adding-platform-adapters.md and an entry
in gateway/platforms/ADDING_A_PLATFORM.md naming the hook.
2026-05-08 12:23:26 +02:00
|
|
|
# Out-of-process cron delivery. Without this hook, deliver=irc
|
|
|
|
|
# cron jobs fail with "No live adapter" when cron runs separately
|
|
|
|
|
# from the gateway.
|
|
|
|
|
standalone_sender_fn=_standalone_send,
|
2026-04-11 15:10:03 -07:00
|
|
|
# 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,
|
2026-04-11 19:27:04 -07:00
|
|
|
# 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."
|
|
|
|
|
),
|
feat: pluggable platform adapter registry + IRC reference implementation
Adds a platform adapter plugin interface so anyone can create new gateway
platforms (IRC, Viber, Line, etc.) as drop-in plugins without modifying
core gateway code.
- PlatformEntry dataclass: name, label, adapter_factory, check_fn,
validate_config, required_env, install_hint, source
- PlatformRegistry singleton with register/unregister/create_adapter
- _create_adapter() in gateway/run.py checks registry first, falls
through to existing if/elif chain for built-in platforms
- Platform._missing_() accepts unknown string values, creating cached
pseudo-members so Platform('irc') is Platform('irc') holds true
- GatewayConfig.from_dict() now parses plugin platform names from
config.yaml without rejecting them
- get_connected_platforms() delegates to registry for unknown platforms
- PluginContext.register_platform() for plugin authors
- Mirrors the existing register_tool() / register_hook() pattern
- Full async IRC adapter using stdlib asyncio (zero external deps)
- Connects via TLS, handles PING/PONG, nick collision, NickServ auth
- Channel messages require addressing (nick: msg), DMs always dispatch
- Markdown stripping for IRC-clean output, message splitting for
512-byte line limit
- Config via config.yaml extra dict or IRC_* env vars
- Platform enum dynamic members (identity stability, case normalization)
- PlatformRegistry (register, unregister, create, validation, factory)
- GatewayConfig integration (from_dict parsing, get_connected_platforms)
- IRC adapter (init, send, protocol parsing, markdown, requirements)
No existing platform adapters were migrated — the if/elif chain is
untouched. This is Phase 1: prove the interface with a real plugin.
2026-04-11 14:25:11 -07:00
|
|
|
)
|