2026-02-02 19:01:51 -08:00
|
|
|
"""
|
|
|
|
|
Gateway configuration management.
|
|
|
|
|
|
|
|
|
|
Handles loading and validating configuration for:
|
|
|
|
|
- Connected platforms (Telegram, Discord, WhatsApp)
|
|
|
|
|
- Home channels for each platform
|
|
|
|
|
- Session reset policies
|
|
|
|
|
- Delivery preferences
|
|
|
|
|
"""
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
import logging
|
2026-02-02 19:01:51 -08:00
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
|
from enum import Enum
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
class Platform(Enum):
|
|
|
|
|
"""Supported messaging platforms."""
|
|
|
|
|
LOCAL = "local"
|
|
|
|
|
TELEGRAM = "telegram"
|
|
|
|
|
DISCORD = "discord"
|
|
|
|
|
WHATSAPP = "whatsapp"
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
SLACK = "slack"
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
SIGNAL = "signal"
|
2026-02-28 13:32:48 +03:00
|
|
|
HOMEASSISTANT = "homeassistant"
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class HomeChannel:
|
|
|
|
|
"""
|
|
|
|
|
Default destination for a platform.
|
|
|
|
|
|
|
|
|
|
When a cron job specifies deliver="telegram" without a specific chat ID,
|
|
|
|
|
messages are sent to this home channel.
|
|
|
|
|
"""
|
|
|
|
|
platform: Platform
|
|
|
|
|
chat_id: str
|
|
|
|
|
name: str # Human-readable name for display
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"platform": self.platform.value,
|
|
|
|
|
"chat_id": self.chat_id,
|
|
|
|
|
"name": self.name,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "HomeChannel":
|
|
|
|
|
return cls(
|
|
|
|
|
platform=Platform(data["platform"]),
|
|
|
|
|
chat_id=str(data["chat_id"]),
|
|
|
|
|
name=data.get("name", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class SessionResetPolicy:
|
|
|
|
|
"""
|
|
|
|
|
Controls when sessions reset (lose context).
|
|
|
|
|
|
|
|
|
|
Modes:
|
|
|
|
|
- "daily": Reset at a specific hour each day
|
|
|
|
|
- "idle": Reset after N minutes of inactivity
|
|
|
|
|
- "both": Whichever triggers first (daily boundary OR idle timeout)
|
2026-02-26 21:20:50 -08:00
|
|
|
- "none": Never auto-reset (context managed only by compression)
|
2026-02-02 19:01:51 -08:00
|
|
|
"""
|
2026-02-26 21:20:50 -08:00
|
|
|
mode: str = "both" # "daily", "idle", "both", or "none"
|
2026-02-02 19:01:51 -08:00
|
|
|
at_hour: int = 4 # Hour for daily reset (0-23, local time)
|
Add background process management with process tool, wait, PTY, and stdin support
New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).
Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL
Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response
Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)
Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform
RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop
Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
2026-02-17 02:51:31 -08:00
|
|
|
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
|
2026-02-02 19:01:51 -08:00
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"mode": self.mode,
|
|
|
|
|
"at_hour": self.at_hour,
|
|
|
|
|
"idle_minutes": self.idle_minutes,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
|
|
|
|
|
return cls(
|
|
|
|
|
mode=data.get("mode", "both"),
|
|
|
|
|
at_hour=data.get("at_hour", 4),
|
Add background process management with process tool, wait, PTY, and stdin support
New process registry and tool for managing long-running background processes
across all terminal backends (local, Docker, Singularity, Modal, SSH).
Process Registry (tools/process_registry.py):
- ProcessSession tracking with rolling 200KB output buffer
- spawn_local() with optional PTY via ptyprocess for interactive CLIs
- spawn_via_env() for non-local backends (runs inside sandbox, never on host)
- Background reader threads per process (Popen stdout or PTY)
- wait() with timeout clamping, interrupt support, and transparent limit reporting
- JSON checkpoint to ~/.hermes/processes.json for gateway crash recovery
- Module-level singleton shared across agent loop, gateway, and RL
Process Tool (model_tools.py):
- 7 actions: list, poll, log, wait, kill, write, submit
- Paired with terminal in all toolsets (CLI, messaging, RL)
- Timeout clamping with transparent notes in response
Terminal Tool Updates (tools/terminal_tool.py):
- Replaced nohup background mode with registry spawn (returns session_id)
- Added workdir parameter for per-command working directory
- Added check_interval parameter for gateway auto-check watchers
- Added pty parameter for interactive CLI tools (Codex, Claude Code)
- Updated TERMINAL_TOOL_DESCRIPTION with full background workflow docs
- Cleanup thread now respects active background processes (won't reap sandbox)
Gateway Integration (gateway/run.py, session.py, config.py):
- Session reset protection: sessions with active processes exempt from reset
- Default idle timeout increased from 2 hours to 24 hours
- from_dict fallback aligned to match (was 120, now 1440)
- session_key env var propagated to process registry for session mapping
- Crash recovery on gateway startup via checkpoint probe
- check_interval watcher: asyncio task polls process, delivers updates to platform
RL Safety (environments/):
- tool_context.py cleanup() kills background processes on episode end
- hermes_base_env.py warns when enabled_toolsets is None (loads all tools)
- Process tool safe in RL via wait() blocking the agent loop
Also:
- Added ptyprocess as optional dependency (in pyproject.toml [pty] extra + [all])
- Fixed pre-existing bug: rl_test_inference missing from TOOL_TO_TOOLSET_MAP
- Updated AGENTS.md with process management docs and project structure
- Updated README.md terminal section with process management overview
2026-02-17 02:51:31 -08:00
|
|
|
idle_minutes=data.get("idle_minutes", 1440),
|
2026-02-02 19:01:51 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class PlatformConfig:
|
|
|
|
|
"""Configuration for a single messaging platform."""
|
|
|
|
|
enabled: bool = False
|
|
|
|
|
token: Optional[str] = None # Bot token (Telegram, Discord)
|
|
|
|
|
api_key: Optional[str] = None # API key if different from token
|
|
|
|
|
home_channel: Optional[HomeChannel] = None
|
|
|
|
|
|
|
|
|
|
# Platform-specific settings
|
|
|
|
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
result = {
|
|
|
|
|
"enabled": self.enabled,
|
|
|
|
|
"extra": self.extra,
|
|
|
|
|
}
|
|
|
|
|
if self.token:
|
|
|
|
|
result["token"] = self.token
|
|
|
|
|
if self.api_key:
|
|
|
|
|
result["api_key"] = self.api_key
|
|
|
|
|
if self.home_channel:
|
|
|
|
|
result["home_channel"] = self.home_channel.to_dict()
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig":
|
|
|
|
|
home_channel = None
|
|
|
|
|
if "home_channel" in data:
|
|
|
|
|
home_channel = HomeChannel.from_dict(data["home_channel"])
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
enabled=data.get("enabled", False),
|
|
|
|
|
token=data.get("token"),
|
|
|
|
|
api_key=data.get("api_key"),
|
|
|
|
|
home_channel=home_channel,
|
|
|
|
|
extra=data.get("extra", {}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class GatewayConfig:
|
|
|
|
|
"""
|
|
|
|
|
Main gateway configuration.
|
|
|
|
|
|
|
|
|
|
Manages all platform connections, session policies, and delivery settings.
|
|
|
|
|
"""
|
|
|
|
|
# Platform configurations
|
|
|
|
|
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
# Session reset policies by type
|
|
|
|
|
default_reset_policy: SessionResetPolicy = field(default_factory=SessionResetPolicy)
|
|
|
|
|
reset_by_type: Dict[str, SessionResetPolicy] = field(default_factory=dict)
|
|
|
|
|
reset_by_platform: Dict[Platform, SessionResetPolicy] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
# Reset trigger commands
|
|
|
|
|
reset_triggers: List[str] = field(default_factory=lambda: ["/new", "/reset"])
|
|
|
|
|
|
|
|
|
|
# Storage paths
|
|
|
|
|
sessions_dir: Path = field(default_factory=lambda: Path.home() / ".hermes" / "sessions")
|
|
|
|
|
|
|
|
|
|
# Delivery settings
|
|
|
|
|
always_log_local: bool = True # Always save cron outputs to local files
|
|
|
|
|
|
|
|
|
|
def get_connected_platforms(self) -> List[Platform]:
|
|
|
|
|
"""Return list of platforms that are enabled and configured."""
|
|
|
|
|
connected = []
|
|
|
|
|
for platform, config in self.platforms.items():
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
if not config.enabled:
|
|
|
|
|
continue
|
|
|
|
|
# Platforms that use token/api_key auth
|
|
|
|
|
if config.token or config.api_key:
|
|
|
|
|
connected.append(platform)
|
|
|
|
|
# WhatsApp uses enabled flag only (bridge handles auth)
|
|
|
|
|
elif platform == Platform.WHATSAPP:
|
|
|
|
|
connected.append(platform)
|
|
|
|
|
# Signal uses extra dict for config (http_url + account)
|
|
|
|
|
elif platform == Platform.SIGNAL and config.extra.get("http_url"):
|
2026-02-02 19:01:51 -08:00
|
|
|
connected.append(platform)
|
|
|
|
|
return connected
|
|
|
|
|
|
|
|
|
|
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
|
|
|
|
"""Get the home channel for a platform."""
|
|
|
|
|
config = self.platforms.get(platform)
|
|
|
|
|
if config:
|
|
|
|
|
return config.home_channel
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def get_reset_policy(
|
|
|
|
|
self,
|
|
|
|
|
platform: Optional[Platform] = None,
|
|
|
|
|
session_type: Optional[str] = None
|
|
|
|
|
) -> SessionResetPolicy:
|
|
|
|
|
"""
|
|
|
|
|
Get the appropriate reset policy for a session.
|
|
|
|
|
|
|
|
|
|
Priority: platform override > type override > default
|
|
|
|
|
"""
|
|
|
|
|
# Platform-specific override takes precedence
|
|
|
|
|
if platform and platform in self.reset_by_platform:
|
|
|
|
|
return self.reset_by_platform[platform]
|
|
|
|
|
|
|
|
|
|
# Type-specific override (dm, group, thread)
|
|
|
|
|
if session_type and session_type in self.reset_by_type:
|
|
|
|
|
return self.reset_by_type[session_type]
|
|
|
|
|
|
|
|
|
|
return self.default_reset_policy
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"platforms": {
|
|
|
|
|
p.value: c.to_dict() for p, c in self.platforms.items()
|
|
|
|
|
},
|
|
|
|
|
"default_reset_policy": self.default_reset_policy.to_dict(),
|
|
|
|
|
"reset_by_type": {
|
|
|
|
|
k: v.to_dict() for k, v in self.reset_by_type.items()
|
|
|
|
|
},
|
|
|
|
|
"reset_by_platform": {
|
|
|
|
|
p.value: v.to_dict() for p, v in self.reset_by_platform.items()
|
|
|
|
|
},
|
|
|
|
|
"reset_triggers": self.reset_triggers,
|
|
|
|
|
"sessions_dir": str(self.sessions_dir),
|
|
|
|
|
"always_log_local": self.always_log_local,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig":
|
|
|
|
|
platforms = {}
|
|
|
|
|
for platform_name, platform_data in data.get("platforms", {}).items():
|
|
|
|
|
try:
|
|
|
|
|
platform = Platform(platform_name)
|
|
|
|
|
platforms[platform] = PlatformConfig.from_dict(platform_data)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass # Skip unknown platforms
|
|
|
|
|
|
|
|
|
|
reset_by_type = {}
|
|
|
|
|
for type_name, policy_data in data.get("reset_by_type", {}).items():
|
|
|
|
|
reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data)
|
|
|
|
|
|
|
|
|
|
reset_by_platform = {}
|
|
|
|
|
for platform_name, policy_data in data.get("reset_by_platform", {}).items():
|
|
|
|
|
try:
|
|
|
|
|
platform = Platform(platform_name)
|
|
|
|
|
reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
default_policy = SessionResetPolicy()
|
|
|
|
|
if "default_reset_policy" in data:
|
|
|
|
|
default_policy = SessionResetPolicy.from_dict(data["default_reset_policy"])
|
|
|
|
|
|
|
|
|
|
sessions_dir = Path.home() / ".hermes" / "sessions"
|
|
|
|
|
if "sessions_dir" in data:
|
|
|
|
|
sessions_dir = Path(data["sessions_dir"])
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
platforms=platforms,
|
|
|
|
|
default_reset_policy=default_policy,
|
|
|
|
|
reset_by_type=reset_by_type,
|
|
|
|
|
reset_by_platform=reset_by_platform,
|
|
|
|
|
reset_triggers=data.get("reset_triggers", ["/new", "/reset"]),
|
|
|
|
|
sessions_dir=sessions_dir,
|
|
|
|
|
always_log_local=data.get("always_log_local", True),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_gateway_config() -> GatewayConfig:
|
|
|
|
|
"""
|
|
|
|
|
Load gateway configuration from multiple sources.
|
|
|
|
|
|
|
|
|
|
Priority (highest to lowest):
|
|
|
|
|
1. Environment variables
|
|
|
|
|
2. ~/.hermes/gateway.json
|
|
|
|
|
3. cli-config.yaml gateway section
|
|
|
|
|
4. Defaults
|
|
|
|
|
"""
|
|
|
|
|
config = GatewayConfig()
|
|
|
|
|
|
|
|
|
|
# Try loading from ~/.hermes/gateway.json
|
|
|
|
|
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
|
|
|
|
if gateway_config_path.exists():
|
|
|
|
|
try:
|
2026-03-05 17:04:33 -05:00
|
|
|
with open(gateway_config_path, "r", encoding="utf-8") as f:
|
2026-02-02 19:01:51 -08:00
|
|
|
data = json.load(f)
|
|
|
|
|
config = GatewayConfig.from_dict(data)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
|
|
|
|
|
|
2026-02-26 21:20:50 -08:00
|
|
|
# Bridge session_reset from config.yaml (the user-facing config file)
|
|
|
|
|
# into the gateway config. config.yaml takes precedence over gateway.json
|
|
|
|
|
# for session reset policy since that's where hermes setup writes it.
|
|
|
|
|
try:
|
|
|
|
|
import yaml
|
|
|
|
|
config_yaml_path = Path.home() / ".hermes" / "config.yaml"
|
|
|
|
|
if config_yaml_path.exists():
|
2026-03-05 17:04:33 -05:00
|
|
|
with open(config_yaml_path, encoding="utf-8") as f:
|
2026-02-26 21:20:50 -08:00
|
|
|
yaml_cfg = yaml.safe_load(f) or {}
|
|
|
|
|
sr = yaml_cfg.get("session_reset")
|
|
|
|
|
if sr and isinstance(sr, dict):
|
|
|
|
|
config.default_reset_policy = SessionResetPolicy.from_dict(sr)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Override with environment variables
|
|
|
|
|
_apply_env_overrides(config)
|
|
|
|
|
|
2026-02-22 02:16:11 -08:00
|
|
|
# --- Validate loaded values ---
|
|
|
|
|
policy = config.default_reset_policy
|
|
|
|
|
|
|
|
|
|
if not (0 <= policy.at_hour <= 23):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Invalid at_hour=%s (must be 0-23). Using default 4.", policy.at_hour
|
|
|
|
|
)
|
|
|
|
|
policy.at_hour = 4
|
|
|
|
|
|
|
|
|
|
if policy.idle_minutes is None or policy.idle_minutes <= 0:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"Invalid idle_minutes=%s (must be positive). Using default 1440.",
|
|
|
|
|
policy.idle_minutes,
|
|
|
|
|
)
|
|
|
|
|
policy.idle_minutes = 1440
|
|
|
|
|
|
|
|
|
|
# Warn about empty bot tokens — platforms that loaded an empty string
|
|
|
|
|
# won't connect and the cause can be confusing without a log line.
|
|
|
|
|
_token_env_names = {
|
|
|
|
|
Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN",
|
|
|
|
|
Platform.DISCORD: "DISCORD_BOT_TOKEN",
|
|
|
|
|
Platform.SLACK: "SLACK_BOT_TOKEN",
|
|
|
|
|
}
|
|
|
|
|
for platform, pconfig in config.platforms.items():
|
|
|
|
|
if not pconfig.enabled:
|
|
|
|
|
continue
|
|
|
|
|
env_name = _token_env_names.get(platform)
|
|
|
|
|
if env_name and pconfig.token is not None and not pconfig.token.strip():
|
|
|
|
|
logger.warning(
|
|
|
|
|
"%s is enabled but %s is empty. "
|
|
|
|
|
"The adapter will likely fail to connect.",
|
|
|
|
|
platform.value, env_name,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_env_overrides(config: GatewayConfig) -> None:
|
|
|
|
|
"""Apply environment variable overrides to config."""
|
|
|
|
|
|
|
|
|
|
# Telegram
|
|
|
|
|
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
|
|
|
|
if telegram_token:
|
|
|
|
|
if Platform.TELEGRAM not in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.TELEGRAM].enabled = True
|
|
|
|
|
config.platforms[Platform.TELEGRAM].token = telegram_token
|
|
|
|
|
|
|
|
|
|
telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL")
|
|
|
|
|
if telegram_home and Platform.TELEGRAM in config.platforms:
|
|
|
|
|
config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.TELEGRAM,
|
|
|
|
|
chat_id=telegram_home,
|
|
|
|
|
name=os.getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Discord
|
|
|
|
|
discord_token = os.getenv("DISCORD_BOT_TOKEN")
|
|
|
|
|
if discord_token:
|
|
|
|
|
if Platform.DISCORD not in config.platforms:
|
|
|
|
|
config.platforms[Platform.DISCORD] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.DISCORD].enabled = True
|
|
|
|
|
config.platforms[Platform.DISCORD].token = discord_token
|
|
|
|
|
|
|
|
|
|
discord_home = os.getenv("DISCORD_HOME_CHANNEL")
|
|
|
|
|
if discord_home and Platform.DISCORD in config.platforms:
|
|
|
|
|
config.platforms[Platform.DISCORD].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.DISCORD,
|
|
|
|
|
chat_id=discord_home,
|
|
|
|
|
name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# WhatsApp (typically uses different auth mechanism)
|
|
|
|
|
whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes")
|
|
|
|
|
if whatsapp_enabled:
|
|
|
|
|
if Platform.WHATSAPP not in config.platforms:
|
|
|
|
|
config.platforms[Platform.WHATSAPP] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.WHATSAPP].enabled = True
|
|
|
|
|
|
Add messaging platform enhancements: STT, stickers, Discord UX, Slack, pairing, hooks
Major feature additions inspired by OpenClaw/ClawdBot integration analysis:
Voice Message Transcription (STT):
- Auto-transcribe voice/audio messages via OpenAI Whisper API
- Download voice to ~/.hermes/audio_cache/ on Telegram/Discord/WhatsApp
- Inject transcript as text so all models can understand voice input
- Configurable model (whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe)
Telegram Sticker Understanding:
- Describe static stickers via vision tool with JSON-backed cache
- Cache keyed by file_unique_id avoids redundant API calls
- Animated/video stickers get emoji-based fallback description
Discord Rich UX:
- Native slash commands (/ask, /reset, /status, /stop) via app_commands
- Button-based exec approvals (Allow Once / Always Allow / Deny)
- ExecApprovalView with user authorization and timeout handling
Slack Integration:
- Full SlackAdapter using slack-bolt with Socket Mode
- DMs, channel messages (mention-gated), /hermes slash command
- File attachment handling with bot-token-authenticated downloads
DM Pairing System:
- Code-based user authorization as alternative to static allowlists
- 8-char codes from unambiguous alphabet, 1-hour expiry
- Rate limiting, lockout after failed attempts, chmod 0600 on data
- CLI: hermes pairing list/approve/revoke/clear-pending
Event Hook System:
- File-based hook discovery from ~/.hermes/hooks/
- HOOK.yaml + handler.py per hook, sync/async handler support
- Events: gateway:startup, session:start/reset, agent:start/step/end
- Wildcard matching (command:* catches all command events)
Cross-Channel Messaging:
- send_message agent tool for delivering to any connected platform
- Enables cron job delivery and cross-platform notifications
Human-Like Response Pacing:
- Configurable delays between message chunks (off/natural/custom)
- HERMES_HUMAN_DELAY_MODE env var with min/max ms settings
Warm Injection Message Style:
- Retrofitted image vision messages with friendly kawaii-consistent tone
- All new injection messages (STT, stickers, errors) use warm style
Also: updated config migration to prompt for optional keys interactively,
bumped config version, updated README, AGENTS.md, .env.example,
cli-config.yaml.example, install scripts, pyproject.toml, and toolsets.
2026-02-15 21:38:59 -08:00
|
|
|
# Slack
|
|
|
|
|
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
|
|
|
|
if slack_token:
|
|
|
|
|
if Platform.SLACK not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SLACK] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SLACK].enabled = True
|
|
|
|
|
config.platforms[Platform.SLACK].token = slack_token
|
|
|
|
|
# Home channel
|
|
|
|
|
slack_home = os.getenv("SLACK_HOME_CHANNEL")
|
|
|
|
|
if slack_home:
|
|
|
|
|
config.platforms[Platform.SLACK].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SLACK,
|
|
|
|
|
chat_id=slack_home,
|
|
|
|
|
name=os.getenv("SLACK_HOME_CHANNEL_NAME", ""),
|
|
|
|
|
)
|
|
|
|
|
|
feat: add Signal messenger gateway platform (#405)
Complete Signal adapter using signal-cli daemon HTTP API.
Based on PR #268 by ibhagwan, rebuilt on current main with bug fixes.
Architecture:
- SSE streaming for inbound messages with exponential backoff (2s→60s)
- JSON-RPC 2.0 for outbound (send, typing, attachments, contacts)
- Health monitor detects stale SSE connections (120s threshold)
- Phone number redaction in all logs and global redact.py
Features:
- DM and group message support with separate access policies
- DM policies: pairing (default), allowlist, open
- Group policies: disabled (default), allowlist, open
- Attachment download with magic-byte type detection
- Typing indicators (8s refresh interval)
- 100MB attachment size limit, 8000 char message limit
- E.164 phone + UUID allowlist support
Integration:
- Platform.SIGNAL enum in gateway/config.py
- Signal in _is_user_authorized() allowlist maps (gateway/run.py)
- Adapter factory in _create_adapter() (gateway/run.py)
- user_id_alt/chat_id_alt fields in SessionSource for UUIDs
- send_message tool support via httpx JSON-RPC (not aiohttp)
- Interactive setup wizard in 'hermes gateway setup'
- Connectivity testing during setup (pings /api/v1/check)
- signal-cli detection and install guidance
Bug fixes from PR #268:
- Timestamp reads from envelope_data (not outer wrapper)
- Uses httpx consistently (not aiohttp in send_message tool)
- SIGNAL_DEBUG scoped to signal logger (not root)
- extract_images regex NOT modified (preserves group numbering)
- pairing.py NOT modified (no cross-platform side effects)
- No dual authorization (adapter defers to run.py for user auth)
- Wildcard uses set membership ('*' in set, not list equality)
- .zip default for PK magic bytes (not .docx)
No new Python dependencies — uses httpx (already core).
External requirement: signal-cli daemon (user-installed).
Tests: 30 new tests covering config, init, helpers, session source,
phone redaction, authorization, and send_message integration.
Co-authored-by: ibhagwan <ibhagwan@users.noreply.github.com>
2026-03-08 20:20:35 -07:00
|
|
|
# Signal
|
|
|
|
|
signal_url = os.getenv("SIGNAL_HTTP_URL")
|
|
|
|
|
signal_account = os.getenv("SIGNAL_ACCOUNT")
|
|
|
|
|
if signal_url and signal_account:
|
|
|
|
|
if Platform.SIGNAL not in config.platforms:
|
|
|
|
|
config.platforms[Platform.SIGNAL] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.SIGNAL].enabled = True
|
|
|
|
|
config.platforms[Platform.SIGNAL].extra.update({
|
|
|
|
|
"http_url": signal_url,
|
|
|
|
|
"account": signal_account,
|
|
|
|
|
"ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"),
|
|
|
|
|
})
|
|
|
|
|
signal_home = os.getenv("SIGNAL_HOME_CHANNEL")
|
|
|
|
|
if signal_home:
|
|
|
|
|
config.platforms[Platform.SIGNAL].home_channel = HomeChannel(
|
|
|
|
|
platform=Platform.SIGNAL,
|
|
|
|
|
chat_id=signal_home,
|
|
|
|
|
name=os.getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"),
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-28 13:32:48 +03:00
|
|
|
# Home Assistant
|
|
|
|
|
hass_token = os.getenv("HASS_TOKEN")
|
|
|
|
|
if hass_token:
|
|
|
|
|
if Platform.HOMEASSISTANT not in config.platforms:
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT] = PlatformConfig()
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].enabled = True
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].token = hass_token
|
|
|
|
|
hass_url = os.getenv("HASS_URL")
|
|
|
|
|
if hass_url:
|
|
|
|
|
config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url
|
|
|
|
|
|
2026-02-02 19:01:51 -08:00
|
|
|
# Session settings
|
|
|
|
|
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
|
|
|
|
if idle_minutes:
|
|
|
|
|
try:
|
|
|
|
|
config.default_reset_policy.idle_minutes = int(idle_minutes)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
reset_hour = os.getenv("SESSION_RESET_HOUR")
|
|
|
|
|
if reset_hour:
|
|
|
|
|
try:
|
|
|
|
|
config.default_reset_policy.at_hour = int(reset_hour)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_gateway_config(config: GatewayConfig) -> None:
|
|
|
|
|
"""Save gateway configuration to ~/.hermes/gateway.json."""
|
|
|
|
|
gateway_config_path = Path.home() / ".hermes" / "gateway.json"
|
|
|
|
|
gateway_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2026-03-05 17:04:33 -05:00
|
|
|
with open(gateway_config_path, "w", encoding="utf-8") as f:
|
2026-02-02 19:01:51 -08:00
|
|
|
json.dump(config.to_dict(), f, indent=2)
|