From b7d6eae64c16d65c1a243f4238479faa9085b983 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sun, 8 Mar 2026 21:00:21 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20Signal=20adapter=20parity=20pass=20?= =?UTF-8?q?=E2=80=94=20integration=20gaps,=20clawdbot=20features,=20env=20?= =?UTF-8?q?var=20simplification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration gaps fixed (7 files missing Signal): - cron/scheduler.py: Signal in platform_map (cron delivery was broken) - agent/prompt_builder.py: PLATFORM_HINTS for Signal (agent knows it's on Signal) - toolsets.py: hermes-signal toolset + added to hermes-gateway composite - hermes_cli/status.py: Signal + Slack in platform status display - tools/send_message_tool.py: Signal example in target description - tools/cronjob_tools.py: Signal in delivery option docs + schema - gateway/channel_directory.py: Signal in session-based channel discovery Clawdbot parity features added to signal.py: - Self-message filtering: prevents reply loops by checking sender != account - SyncMessage filtering: ignores sync envelopes (sent transcripts, read receipts) - Edit message support: reads dataMessage from editMessage envelope - Mention rendering: replaces \uFFFC placeholders with @identifier text - Jitter in SSE reconnection backoff (20% randomization, prevents thundering herd) Env var simplification (7 → 4): - Removed SIGNAL_DM_POLICY (DM auth follows standard platform pattern via SIGNAL_ALLOWED_USERS + DM pairing, same as Telegram/Discord) - Removed SIGNAL_GROUP_POLICY (derived from SIGNAL_GROUP_ALLOWED_USERS: not set = disabled, set with IDs = allowlist, set with * = open) - Removed SIGNAL_DEBUG (was setting root logger, removed entirely) - Remaining: SIGNAL_HTTP_URL, SIGNAL_ACCOUNT (required), SIGNAL_ALLOWED_USERS, SIGNAL_GROUP_ALLOWED_USERS (optional) Updated all docs (website, AGENTS.md, signal.md) to match. --- AGENTS.md | 2 +- agent/prompt_builder.py | 9 + cron/scheduler.py | 1 + gateway/channel_directory.py | 4 +- gateway/config.py | 2 - gateway/platforms/signal.py | 92 +- hermes_cli/gateway.py | 31 +- hermes_cli/status.py | 2 + tests/gateway/test_signal.py | 39 +- tools/cronjob_tools.py | 4 +- tools/send_message_tool.py | 2 +- toolsets.py | 1028 +++++++++-------- .../docs/reference/environment-variables.md | 5 +- website/docs/user-guide/messaging/signal.md | 45 +- 14 files changed, 645 insertions(+), 621 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 30c4d385..2a183cf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,7 +396,7 @@ DISCORD_ALLOWED_USERS=123456789012345678 # Comma-separated user IDs # Signal SIGNAL_HTTP_URL=http://127.0.0.1:8080 # signal-cli daemon URL SIGNAL_ACCOUNT=+1234567890 # Bot phone number (E.164) -SIGNAL_ALLOWED_USERS=+1234567890 # Comma-separated E.164 numbers or UUIDs +SIGNAL_ALLOWED_USERS=+1234567890 # Comma-separated E.164 numbers/UUIDs # Agent Behavior HERMES_MAX_ITERATIONS=90 # Max tool-calling iterations (default: 90) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index c933ffe6..c2aedc19 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -122,6 +122,15 @@ PLATFORM_HINTS = { "attachments, audio as file attachments. You can also include image URLs " "in markdown format ![alt](url) and they will be uploaded as attachments." ), + "signal": ( + "You are on a text messaging communication platform, Signal. " + "Please do not use markdown as it does not render. " + "You can send media files natively: to deliver a file to the user, " + "include MEDIA:/absolute/path/to/file in your response. Images " + "(.png, .jpg, .webp) appear as photos, audio as attachments, and other " + "files arrive as downloadable documents. You can also include image " + "URLs in markdown format ![alt](url) and they will be sent as photos." + ), "cli": ( "You are a CLI AI Agent. Try not to use markdown but simple text " "renderable inside a terminal." diff --git a/cron/scheduler.py b/cron/scheduler.py index 4dfc91e0..1f96d644 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -98,6 +98,7 @@ def _deliver_result(job: dict, content: str) -> None: "discord": Platform.DISCORD, "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, + "signal": Platform.SIGNAL, } platform = platform_map.get(platform_name.lower()) if not platform: diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 622fed6b..538f5e80 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -40,8 +40,8 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: except Exception as e: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) - # Telegram & WhatsApp can't enumerate chats -- pull from session history - for plat_name in ("telegram", "whatsapp"): + # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history + for plat_name in ("telegram", "whatsapp", "signal"): if plat_name not in platforms: platforms[plat_name] = _build_from_sessions(plat_name) diff --git a/gateway/config.py b/gateway/config.py index b52c03d2..9a517f81 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -399,8 +399,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.SIGNAL].extra.update({ "http_url": signal_url, "account": signal_account, - "dm_policy": os.getenv("SIGNAL_DM_POLICY", "pairing"), - "group_policy": os.getenv("SIGNAL_GROUP_POLICY", "disabled"), "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"), }) signal_home = os.getenv("SIGNAL_HOME_CHANNEL") diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 85963aa6..62e7e4b6 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -16,6 +16,7 @@ import base64 import json import logging import os +import random import re import time from datetime import datetime, timezone @@ -103,6 +104,27 @@ def _is_audio_ext(ext: str) -> bool: return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac") +def _render_mentions(text: str, mentions: list) -> str: + """Replace Signal mention placeholders (\\uFFFC) with readable @identifiers. + + Signal encodes @mentions as the Unicode object replacement character + with out-of-band metadata containing the mentioned user's UUID/number. + """ + if not mentions or "\uFFFC" not in text: + return text + # Sort mentions by start position (reverse) to replace from end to start + # so indices don't shift as we replace + sorted_mentions = sorted(mentions, key=lambda m: m.get("start", 0), reverse=True) + for mention in sorted_mentions: + start = mention.get("start", 0) + length = mention.get("length", 1) + # Use the mention's number or UUID as the replacement + identifier = mention.get("number") or mention.get("uuid") or "user" + replacement = f"@{identifier}" + text = text[:start] + replacement + text[start + length:] + return text + + def check_signal_requirements() -> bool: """Check if Signal is configured (has URL and account).""" return bool(os.getenv("SIGNAL_HTTP_URL") and os.getenv("SIGNAL_ACCOUNT")) @@ -123,13 +145,9 @@ class SignalAdapter(BasePlatformAdapter): extra = config.extra or {} self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/") self.account = extra.get("account", "") - self.dm_policy = extra.get("dm_policy", "pairing") - self.group_policy = extra.get("group_policy", "disabled") self.ignore_stories = extra.get("ignore_stories", True) - # Parse allowlists - allowed_str = os.getenv("SIGNAL_ALLOWED_USERS", "") - self.allowed_users = set(_parse_comma_list(allowed_str)) + # Parse allowlists — group policy is derived from presence of group allowlist group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "") self.group_allow_from = set(_parse_comma_list(group_allowed_str)) @@ -144,16 +162,12 @@ class SignalAdapter(BasePlatformAdapter): self._last_sse_activity = 0.0 self._sse_response: Optional[httpx.Response] = None - # Pairing store (lazy import to avoid circular deps) - from gateway.pairing import PairingStore - self.pairing_store = PairingStore() + # Normalize account for self-message filtering + self._account_normalized = self.account.strip() - # Debug logging (scoped to this module, NOT root logger) - if os.getenv("SIGNAL_DEBUG", "").lower() in ("true", "1", "yes"): - logger.setLevel(logging.DEBUG) - - logger.info("Signal adapter initialized: url=%s account=%s dm_policy=%s group_policy=%s", - self.http_url, _redact_phone(self.account), self.dm_policy, self.group_policy) + logger.info("Signal adapter initialized: url=%s account=%s groups=%s", + self.http_url, _redact_phone(self.account), + "enabled" if self.group_allow_from else "disabled") # ------------------------------------------------------------------ # Lifecycle @@ -270,7 +284,9 @@ class SignalAdapter(BasePlatformAdapter): logger.warning("Signal SSE: error: %s (reconnecting in %.0fs)", e, backoff) if self._running: - await asyncio.sleep(backoff) + # Add 20% jitter to prevent thundering herd on reconnection + jitter = backoff * 0.2 * random.random() + await asyncio.sleep(backoff + jitter) backoff = min(backoff * 2, SSE_RETRY_DELAY_MAX) self._sse_response = None @@ -323,6 +339,11 @@ class SignalAdapter(BasePlatformAdapter): # Unwrap nested envelope if present envelope_data = envelope.get("envelope", envelope) + # Filter syncMessage envelopes (sent transcripts, read receipts, etc.) + # signal-cli may set syncMessage to null vs omitting it, so check key existence + if "syncMessage" in envelope_data: + return + # Extract sender info sender = ( envelope_data.get("sourceNumber") @@ -336,12 +357,20 @@ class SignalAdapter(BasePlatformAdapter): logger.debug("Signal: ignoring envelope with no sender") return + # Self-message filtering — prevent reply loops + if self._account_normalized and sender == self._account_normalized: + return + # Filter stories if self.ignore_stories and envelope_data.get("storyMessage"): return - # Get data message (skip receipts, typing indicators, etc.) - data_message = envelope_data.get("dataMessage") + # Get data message — also check editMessage (edited messages contain + # their updated dataMessage inside editMessage.dataMessage) + data_message = ( + envelope_data.get("dataMessage") + or (envelope_data.get("editMessage") or {}).get("dataMessage") + ) if not data_message: return @@ -350,29 +379,28 @@ class SignalAdapter(BasePlatformAdapter): group_id = group_info.get("groupId") if group_info else None is_group = bool(group_id) - # Authorization check — delegated to run.py's _is_user_authorized() - # for DM allowlists. We only do group policy filtering here since - # that's Signal-specific and not in the base auth system. + # Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS: + # - No env var set → groups disabled (default safe behavior) + # - Env var set with group IDs → only those groups allowed + # - Env var set with "*" → all groups allowed + # DM auth is fully handled by run.py (_is_user_authorized) if is_group: - if self.group_policy == "disabled": - logger.debug("Signal: ignoring group message (group_policy=disabled)") + if not self.group_allow_from: + logger.debug("Signal: ignoring group message (no SIGNAL_GROUP_ALLOWED_USERS)") + return + if "*" not in self.group_allow_from and group_id not in self.group_allow_from: + logger.debug("Signal: group %s not in allowlist", group_id[:8] if group_id else "?") return - if self.group_policy == "allowlist": - if "*" not in self.group_allow_from and group_id not in self.group_allow_from: - logger.debug("Signal: group %s not in allowlist", group_id[:8] if group_id else "?") - return - # group_policy == "open" — allow through - - # DM policy "open" — for non-group, let all through to run.py auth - # (run.py will still check SIGNAL_ALLOWED_USERS / pairing) - # DM policy "pairing" / "allowlist" — handled by run.py # Build chat info chat_id = sender if not is_group else f"group:{group_id}" chat_type = "group" if is_group else "dm" - # Extract text + # Extract text and render mentions text = data_message.get("message", "") + mentions = data_message.get("mentions", []) + if text and mentions: + text = _render_mentions(text, mentions) # Process attachments attachments_data = data_message.get("attachments", []) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f4ed18e0..64fe551b 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -757,42 +757,25 @@ def _setup_signal(): save_env_value("SIGNAL_ALLOWED_USERS", allowed) - # DM policy + # Group messaging print() - policies = ["pairing (default — new users get a pairing code to approve)", - "allowlist (only explicitly listed users)", - "open (anyone can message)"] - dm_choice = prompt_choice(" DM access policy:", policies, 0) - dm_policy = ["pairing", "allowlist", "open"][dm_choice] - save_env_value("SIGNAL_DM_POLICY", dm_policy) - - # Group policy - print() - group_policies = ["disabled (default — ignore group messages)", - "allowlist (only specific groups)", - "open (respond in any group the bot is in)"] - group_choice = prompt_choice(" Group message policy:", group_policies, 0) - group_policy = ["disabled", "allowlist", "open"][group_choice] - save_env_value("SIGNAL_GROUP_POLICY", group_policy) - - if group_policy == "allowlist": + if prompt_yes_no(" Enable group messaging? (disabled by default for security)", False): print() - print_info(" Enter group IDs to allow (comma-separated).") + print_info(" Enter group IDs to allow, or * for all groups.") existing_groups = get_env_value("SIGNAL_GROUP_ALLOWED_USERS") or "" try: - groups = input(f" Group IDs [{existing_groups}]: ").strip() or existing_groups + groups = input(f" Group IDs [{existing_groups or '*'}]: ").strip() or existing_groups or "*" except (EOFError, KeyboardInterrupt): print("\n Setup cancelled.") return - if groups: - save_env_value("SIGNAL_GROUP_ALLOWED_USERS", groups) + save_env_value("SIGNAL_GROUP_ALLOWED_USERS", groups) print() print_success("Signal configured!") print_info(f" URL: {url}") print_info(f" Account: {account}") - print_info(f" DM policy: {dm_policy}") - print_info(f" Group policy: {group_policy}") + print_info(f" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing") + print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}") def gateway_setup(): diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 3ee666b4..12b064fe 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -206,6 +206,8 @@ def show_status(args): "Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"), "Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"), "WhatsApp": ("WHATSAPP_ENABLED", None), + "Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"), + "Slack": ("SLACK_BOT_TOKEN", None), } for name, (token_var, home_var) in platforms.items(): diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 81d6bf49..f7e32dbb 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -23,8 +23,6 @@ class TestSignalConfigLoading: def test_apply_env_overrides_signal(self, monkeypatch): monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567") - monkeypatch.setenv("SIGNAL_DM_POLICY", "open") - monkeypatch.setenv("SIGNAL_GROUP_POLICY", "allowlist") from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() @@ -35,8 +33,6 @@ class TestSignalConfigLoading: assert sc.enabled is True assert sc.extra["http_url"] == "http://localhost:9090" assert sc.extra["account"] == "+15551234567" - assert sc.extra["dm_policy"] == "open" - assert sc.extra["group_policy"] == "allowlist" def test_signal_not_loaded_without_both_vars(self, monkeypatch): monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") @@ -71,49 +67,44 @@ class TestSignalAdapterInit: config.extra = { "http_url": "http://localhost:8080", "account": "+15551234567", - "dm_policy": "pairing", - "group_policy": "disabled", **extra, } return config def test_init_parses_config(self, monkeypatch): - monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "+15559876543,+15551111111") monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "group123,group456") - monkeypatch.delenv("SIGNAL_DEBUG", raising=False) from gateway.platforms.signal import SignalAdapter adapter = SignalAdapter(self._make_config()) assert adapter.http_url == "http://localhost:8080" assert adapter.account == "+15551234567" - assert adapter.dm_policy == "pairing" - assert adapter.group_policy == "disabled" - assert "+15559876543" in adapter.allowed_users - assert "+15551111111" in adapter.allowed_users assert "group123" in adapter.group_allow_from def test_init_empty_allowlist(self, monkeypatch): - monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "") monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") - monkeypatch.delenv("SIGNAL_DEBUG", raising=False) from gateway.platforms.signal import SignalAdapter adapter = SignalAdapter(self._make_config()) - assert len(adapter.allowed_users) == 0 assert len(adapter.group_allow_from) == 0 def test_init_strips_trailing_slash(self, monkeypatch): - monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "") monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") - monkeypatch.delenv("SIGNAL_DEBUG", raising=False) from gateway.platforms.signal import SignalAdapter adapter = SignalAdapter(self._make_config(http_url="http://localhost:8080/")) assert adapter.http_url == "http://localhost:8080" + def test_self_message_filtering(self, monkeypatch): + monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") + + from gateway.platforms.signal import SignalAdapter + adapter = SignalAdapter(self._make_config()) + + assert adapter._account_normalized == "+15551234567" + class TestSignalHelpers: def test_redact_phone_long(self): @@ -177,6 +168,20 @@ class TestSignalHelpers: monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567") assert check_signal_requirements() is True + def test_render_mentions(self): + from gateway.platforms.signal import _render_mentions + text = "Hello \uFFFC, how are you?" + mentions = [{"start": 6, "length": 1, "number": "+15559999999"}] + result = _render_mentions(text, mentions) + assert "@+15559999999" in result + assert "\uFFFC" not in result + + def test_render_mentions_no_mentions(self): + from gateway.platforms.signal import _render_mentions + text = "Hello world" + result = _render_mentions(text, []) + assert result == "Hello world" + def test_check_requirements_missing(self, monkeypatch): from gateway.platforms.signal import check_signal_requirements monkeypatch.delenv("SIGNAL_HTTP_URL", raising=False) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index cfca76a7..bdfa58d6 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -102,7 +102,9 @@ def schedule_cronjob( - "local": Save to local files only (~/.hermes/cron/output/) - "telegram": Send to Telegram home channel - "discord": Send to Discord home channel + - "signal": Send to Signal home channel - "telegram:123456": Send to specific chat ID + - "signal:+15551234567": Send to specific Signal number Returns: JSON with job_id, next_run time, and confirmation @@ -216,7 +218,7 @@ Use for: reminders, periodic checks, scheduled reports, automated maintenance."" }, "deliver": { "type": "string", - "description": "Where to send output: 'origin' (back to this chat), 'local' (files only), 'telegram', 'discord', or 'platform:chat_id'" + "description": "Where to send output: 'origin' (back to this chat), 'local' (files only), 'telegram', 'discord', 'signal', or 'platform:chat_id'" } }, "required": ["prompt", "schedule"] diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 8e5c0d4f..8f5dbb61 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -33,7 +33,7 @@ SEND_MESSAGE_SCHEMA = { }, "target": { "type": "string", - "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', or 'platform:chat_id'. Examples: 'telegram', 'discord:#bot-home', 'slack:#engineering'" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', or 'platform:chat_id'. Examples: 'telegram', 'discord:#bot-home', 'slack:#engineering', 'signal:+15551234567'" }, "message": { "type": "string", diff --git a/toolsets.py b/toolsets.py index 8589a35e..87b48c7e 100644 --- a/toolsets.py +++ b/toolsets.py @@ -1,511 +1,517 @@ -#!/usr/bin/env python3 -""" -Toolsets Module - -This module provides a flexible system for defining and managing tool aliases/toolsets. -Toolsets allow you to group tools together for specific scenarios and can be composed -from individual tools or other toolsets. - -Features: -- Define custom toolsets with specific tools -- Compose toolsets from other toolsets -- Built-in common toolsets for typical use cases -- Easy extension for new toolsets -- Support for dynamic toolset resolution - -Usage: - from toolsets import get_toolset, resolve_toolset, get_all_toolsets - - # Get tools for a specific toolset - tools = get_toolset("research") - - # Resolve a toolset to get all tool names (including from composed toolsets) - all_tools = resolve_toolset("full_stack") -""" - -from typing import List, Dict, Any, Set, Optional - - -# Shared tool list for CLI and all messaging platform toolsets. -# Edit this once to update all platforms simultaneously. -_HERMES_CORE_TOOLS = [ - # Web - "web_search", "web_extract", - # Terminal + process management - "terminal", "process", - # File manipulation - "read_file", "write_file", "patch", "search_files", - # Vision + image generation - "vision_analyze", "image_generate", - # MoA - "mixture_of_agents", - # Skills - "skills_list", "skill_view", "skill_manage", - # Browser automation - "browser_navigate", "browser_snapshot", "browser_click", - "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", - "browser_vision", - # Text-to-speech - "text_to_speech", - # Planning & memory - "todo", "memory", - # Session history search - "session_search", - # Clarifying questions - "clarify", - # Code execution + delegation - "execute_code", "delegate_task", - # Cronjob management - "schedule_cronjob", "list_cronjobs", "remove_cronjob", - # Cross-platform messaging (gated on gateway running via check_fn) - "send_message", - # Honcho user context (gated on honcho being active via check_fn) - "query_user_context", - # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) - "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", -] - - -# Core toolset definitions -# These can include individual tools or reference other toolsets -TOOLSETS = { - # Basic toolsets - individual tool categories - "web": { - "description": "Web research and content extraction tools", - "tools": ["web_search", "web_extract"], - "includes": [] # No other toolsets included - }, - - "search": { - "description": "Web search only (no content extraction/scraping)", - "tools": ["web_search"], - "includes": [] - }, - - "vision": { - "description": "Image analysis and vision tools", - "tools": ["vision_analyze"], - "includes": [] - }, - - "image_gen": { - "description": "Creative generation tools (images)", - "tools": ["image_generate"], - "includes": [] - }, - - "terminal": { - "description": "Terminal/command execution and process management tools", - "tools": ["terminal", "process"], - "includes": [] - }, - - "moa": { - "description": "Advanced reasoning and problem-solving tools", - "tools": ["mixture_of_agents"], - "includes": [] - }, - - "skills": { - "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge", - "tools": ["skills_list", "skill_view", "skill_manage"], - "includes": [] - }, - - "browser": { - "description": "Browser automation for web interaction (navigate, click, type, scroll, iframes, hold-click) with web search for finding URLs", - "tools": [ - "browser_navigate", "browser_snapshot", "browser_click", - "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", - "browser_vision", "web_search" - ], - "includes": [] - }, - - "cronjob": { - "description": "Cronjob management tools - schedule, list, and remove automated tasks", - "tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"], - "includes": [] - }, - - "rl": { - "description": "RL training tools for running reinforcement learning on Tinker-Atropos", - "tools": [ - "rl_list_environments", "rl_select_environment", - "rl_get_current_config", "rl_edit_config", - "rl_start_training", "rl_check_status", - "rl_stop_training", "rl_get_results", - "rl_list_runs", "rl_test_inference" - ], - "includes": [] - }, - - "file": { - "description": "File manipulation tools: read, write, patch (with fuzzy matching), and search (content + files)", - "tools": ["read_file", "write_file", "patch", "search_files"], - "includes": [] - }, - - "tts": { - "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, or OpenAI", - "tools": ["text_to_speech"], - "includes": [] - }, - - "todo": { - "description": "Task planning and tracking for multi-step work", - "tools": ["todo"], - "includes": [] - }, - - "memory": { - "description": "Persistent memory across sessions (personal notes + user profile)", - "tools": ["memory"], - "includes": [] - }, - - "session_search": { - "description": "Search and recall past conversations with summarization", - "tools": ["session_search"], - "includes": [] - }, - - "clarify": { - "description": "Ask the user clarifying questions (multiple-choice or open-ended)", - "tools": ["clarify"], - "includes": [] - }, - - "code_execution": { - "description": "Run Python scripts that call tools programmatically (reduces LLM round trips)", - "tools": ["execute_code"], - "includes": [] - }, - - "delegation": { - "description": "Spawn subagents with isolated context for complex subtasks", - "tools": ["delegate_task"], - "includes": [] - }, - - "honcho": { - "description": "Honcho AI-native memory for persistent cross-session user modeling", - "tools": ["query_user_context"], - "includes": [] - }, - - "homeassistant": { - "description": "Home Assistant smart home control and monitoring", - "tools": ["ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service"], - "includes": [] - }, - - - # Scenario-specific toolsets - - "debugging": { - "description": "Debugging and troubleshooting toolkit", - "tools": ["terminal", "process"], - "includes": ["web", "file"] # For searching error messages and solutions, and file operations - }, - - "safe": { - "description": "Safe toolkit without terminal access", - "tools": ["mixture_of_agents"], - "includes": ["web", "vision", "image_gen"] - }, - - # ========================================================================== - # Full Hermes toolsets (CLI + messaging platforms) - # - # All platforms share the same core tools. Messaging platforms add - # All platforms share the same core tools (including send_message, - # which is gated on gateway running via its check_fn). - # ========================================================================== - - "hermes-cli": { - "description": "Full interactive CLI toolset - all default tools plus cronjob management", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, - - "hermes-telegram": { - "description": "Telegram bot toolset - full access for personal use (terminal has safety checks)", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, - - "hermes-discord": { - "description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, - - "hermes-whatsapp": { - "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, - - "hermes-slack": { - "description": "Slack bot toolset - full access for workspace use (terminal has safety checks)", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, - - "hermes-homeassistant": { - "description": "Home Assistant bot toolset - smart home event monitoring and control", - "tools": _HERMES_CORE_TOOLS, - "includes": [] - }, - - "hermes-gateway": { - "description": "Gateway toolset - union of all messaging platform tools", - "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-homeassistant"] - } -} - - - -def get_toolset(name: str) -> Optional[Dict[str, Any]]: - """ - Get a toolset definition by name. - - Args: - name (str): Name of the toolset - - Returns: - Dict: Toolset definition with description, tools, and includes - None: If toolset not found - """ - # Return toolset definition - return TOOLSETS.get(name) - - -def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: - """ - Recursively resolve a toolset to get all tool names. - - This function handles toolset composition by recursively resolving - included toolsets and combining all tools. - - Args: - name (str): Name of the toolset to resolve - visited (Set[str]): Set of already visited toolsets (for cycle detection) - - Returns: - List[str]: List of all tool names in the toolset - """ - if visited is None: - visited = set() - - # Special aliases that represent all tools across every toolset - # This ensures future toolsets are automatically included without changes. - if name in {"all", "*"}: - all_tools: Set[str] = set() - for toolset_name in get_toolset_names(): - # Use a fresh visited set per branch to avoid cross-branch contamination - resolved = resolve_toolset(toolset_name, visited.copy()) - all_tools.update(resolved) - return list(all_tools) - - # Check for cycles - if name in visited: - print(f"⚠️ Circular dependency detected in toolset '{name}'") - return [] - - visited.add(name) - - # Get toolset definition - toolset = TOOLSETS.get(name) - if not toolset: - return [] - - # Collect direct tools - tools = set(toolset.get("tools", [])) - - # Recursively resolve included toolsets - for included_name in toolset.get("includes", []): - included_tools = resolve_toolset(included_name, visited.copy()) - tools.update(included_tools) - - return list(tools) - - -def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]: - """ - Resolve multiple toolsets and combine their tools. - - Args: - toolset_names (List[str]): List of toolset names to resolve - - Returns: - List[str]: Combined list of all tool names (deduplicated) - """ - all_tools = set() - - for name in toolset_names: - tools = resolve_toolset(name) - all_tools.update(tools) - - return list(all_tools) - - -def get_all_toolsets() -> Dict[str, Dict[str, Any]]: - """ - Get all available toolsets with their definitions. - - Returns: - Dict: All toolset definitions - """ - return TOOLSETS.copy() - - -def get_toolset_names() -> List[str]: - """ - Get names of all available toolsets (excluding aliases). - - Returns: - List[str]: List of toolset names - """ - return list(TOOLSETS.keys()) - - - - -def validate_toolset(name: str) -> bool: - """ - Check if a toolset name is valid. - - Args: - name (str): Toolset name to validate - - Returns: - bool: True if valid, False otherwise - """ - # Accept special alias names for convenience - if name in {"all", "*"}: - return True - return name in TOOLSETS - - -def create_custom_toolset( - name: str, - description: str, - tools: List[str] = None, - includes: List[str] = None -) -> None: - """ - Create a custom toolset at runtime. - - Args: - name (str): Name for the new toolset - description (str): Description of the toolset - tools (List[str]): Direct tools to include - includes (List[str]): Other toolsets to include - """ - TOOLSETS[name] = { - "description": description, - "tools": tools or [], - "includes": includes or [] - } - - - - -def get_toolset_info(name: str) -> Dict[str, Any]: - """ - Get detailed information about a toolset including resolved tools. - - Args: - name (str): Toolset name - - Returns: - Dict: Detailed toolset information - """ - toolset = get_toolset(name) - if not toolset: - return None - - resolved_tools = resolve_toolset(name) - - return { - "name": name, - "description": toolset["description"], - "direct_tools": toolset["tools"], - "includes": toolset["includes"], - "resolved_tools": resolved_tools, - "tool_count": len(resolved_tools), - "is_composite": len(toolset["includes"]) > 0 - } - - -def print_toolset_tree(name: str, indent: int = 0) -> None: - """ - Print a tree view of a toolset and its composition. - - Args: - name (str): Toolset name - indent (int): Current indentation level - """ - prefix = " " * indent - toolset = get_toolset(name) - - if not toolset: - print(f"{prefix}❌ Unknown toolset: {name}") - return - - # Print toolset name and description - print(f"{prefix}📦 {name}: {toolset['description']}") - - # Print direct tools - if toolset["tools"]: - print(f"{prefix} 🔧 Tools: {', '.join(toolset['tools'])}") - - # Print included toolsets - if toolset["includes"]: - print(f"{prefix} 📂 Includes:") - for included in toolset["includes"]: - print_toolset_tree(included, indent + 2) - - -if __name__ == "__main__": - print("Toolsets System Demo") - print("=" * 60) - - print("\nAvailable Toolsets:") - print("-" * 40) - for name, toolset in get_all_toolsets().items(): - info = get_toolset_info(name) - composite = "[composite]" if info["is_composite"] else "[leaf]" - print(f" {composite} {name:20} - {toolset['description']}") - print(f" Tools: {len(info['resolved_tools'])} total") - - print("\nToolset Resolution Examples:") - print("-" * 40) - for name in ["web", "terminal", "safe", "debugging"]: - tools = resolve_toolset(name) - print(f"\n {name}:") - print(f" Resolved to {len(tools)} tools: {', '.join(sorted(tools))}") - - print("\nMultiple Toolset Resolution:") - print("-" * 40) - combined = resolve_multiple_toolsets(["web", "vision", "terminal"]) - print(f" Combining ['web', 'vision', 'terminal']:") - print(f" Result: {', '.join(sorted(combined))}") - - print("\nCustom Toolset Creation:") - print("-" * 40) - create_custom_toolset( - name="my_custom", - description="My custom toolset for specific tasks", - tools=["web_search"], - includes=["terminal", "vision"] - ) - custom_info = get_toolset_info("my_custom") - print(f" Created 'my_custom' toolset:") - print(f" Description: {custom_info['description']}") - print(f" Resolved tools: {', '.join(custom_info['resolved_tools'])}") +#!/usr/bin/env python3 +""" +Toolsets Module + +This module provides a flexible system for defining and managing tool aliases/toolsets. +Toolsets allow you to group tools together for specific scenarios and can be composed +from individual tools or other toolsets. + +Features: +- Define custom toolsets with specific tools +- Compose toolsets from other toolsets +- Built-in common toolsets for typical use cases +- Easy extension for new toolsets +- Support for dynamic toolset resolution + +Usage: + from toolsets import get_toolset, resolve_toolset, get_all_toolsets + + # Get tools for a specific toolset + tools = get_toolset("research") + + # Resolve a toolset to get all tool names (including from composed toolsets) + all_tools = resolve_toolset("full_stack") +""" + +from typing import List, Dict, Any, Set, Optional + + +# Shared tool list for CLI and all messaging platform toolsets. +# Edit this once to update all platforms simultaneously. +_HERMES_CORE_TOOLS = [ + # Web + "web_search", "web_extract", + # Terminal + process management + "terminal", "process", + # File manipulation + "read_file", "write_file", "patch", "search_files", + # Vision + image generation + "vision_analyze", "image_generate", + # MoA + "mixture_of_agents", + # Skills + "skills_list", "skill_view", "skill_manage", + # Browser automation + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_close", "browser_get_images", + "browser_vision", + # Text-to-speech + "text_to_speech", + # Planning & memory + "todo", "memory", + # Session history search + "session_search", + # Clarifying questions + "clarify", + # Code execution + delegation + "execute_code", "delegate_task", + # Cronjob management + "schedule_cronjob", "list_cronjobs", "remove_cronjob", + # Cross-platform messaging (gated on gateway running via check_fn) + "send_message", + # Honcho user context (gated on honcho being active via check_fn) + "query_user_context", + # Home Assistant smart home control (gated on HASS_TOKEN via check_fn) + "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", +] + + +# Core toolset definitions +# These can include individual tools or reference other toolsets +TOOLSETS = { + # Basic toolsets - individual tool categories + "web": { + "description": "Web research and content extraction tools", + "tools": ["web_search", "web_extract"], + "includes": [] # No other toolsets included + }, + + "search": { + "description": "Web search only (no content extraction/scraping)", + "tools": ["web_search"], + "includes": [] + }, + + "vision": { + "description": "Image analysis and vision tools", + "tools": ["vision_analyze"], + "includes": [] + }, + + "image_gen": { + "description": "Creative generation tools (images)", + "tools": ["image_generate"], + "includes": [] + }, + + "terminal": { + "description": "Terminal/command execution and process management tools", + "tools": ["terminal", "process"], + "includes": [] + }, + + "moa": { + "description": "Advanced reasoning and problem-solving tools", + "tools": ["mixture_of_agents"], + "includes": [] + }, + + "skills": { + "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge", + "tools": ["skills_list", "skill_view", "skill_manage"], + "includes": [] + }, + + "browser": { + "description": "Browser automation for web interaction (navigate, click, type, scroll, iframes, hold-click) with web search for finding URLs", + "tools": [ + "browser_navigate", "browser_snapshot", "browser_click", + "browser_type", "browser_scroll", "browser_back", + "browser_press", "browser_close", "browser_get_images", + "browser_vision", "web_search" + ], + "includes": [] + }, + + "cronjob": { + "description": "Cronjob management tools - schedule, list, and remove automated tasks", + "tools": ["schedule_cronjob", "list_cronjobs", "remove_cronjob"], + "includes": [] + }, + + "rl": { + "description": "RL training tools for running reinforcement learning on Tinker-Atropos", + "tools": [ + "rl_list_environments", "rl_select_environment", + "rl_get_current_config", "rl_edit_config", + "rl_start_training", "rl_check_status", + "rl_stop_training", "rl_get_results", + "rl_list_runs", "rl_test_inference" + ], + "includes": [] + }, + + "file": { + "description": "File manipulation tools: read, write, patch (with fuzzy matching), and search (content + files)", + "tools": ["read_file", "write_file", "patch", "search_files"], + "includes": [] + }, + + "tts": { + "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, or OpenAI", + "tools": ["text_to_speech"], + "includes": [] + }, + + "todo": { + "description": "Task planning and tracking for multi-step work", + "tools": ["todo"], + "includes": [] + }, + + "memory": { + "description": "Persistent memory across sessions (personal notes + user profile)", + "tools": ["memory"], + "includes": [] + }, + + "session_search": { + "description": "Search and recall past conversations with summarization", + "tools": ["session_search"], + "includes": [] + }, + + "clarify": { + "description": "Ask the user clarifying questions (multiple-choice or open-ended)", + "tools": ["clarify"], + "includes": [] + }, + + "code_execution": { + "description": "Run Python scripts that call tools programmatically (reduces LLM round trips)", + "tools": ["execute_code"], + "includes": [] + }, + + "delegation": { + "description": "Spawn subagents with isolated context for complex subtasks", + "tools": ["delegate_task"], + "includes": [] + }, + + "honcho": { + "description": "Honcho AI-native memory for persistent cross-session user modeling", + "tools": ["query_user_context"], + "includes": [] + }, + + "homeassistant": { + "description": "Home Assistant smart home control and monitoring", + "tools": ["ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service"], + "includes": [] + }, + + + # Scenario-specific toolsets + + "debugging": { + "description": "Debugging and troubleshooting toolkit", + "tools": ["terminal", "process"], + "includes": ["web", "file"] # For searching error messages and solutions, and file operations + }, + + "safe": { + "description": "Safe toolkit without terminal access", + "tools": ["mixture_of_agents"], + "includes": ["web", "vision", "image_gen"] + }, + + # ========================================================================== + # Full Hermes toolsets (CLI + messaging platforms) + # + # All platforms share the same core tools. Messaging platforms add + # All platforms share the same core tools (including send_message, + # which is gated on gateway running via its check_fn). + # ========================================================================== + + "hermes-cli": { + "description": "Full interactive CLI toolset - all default tools plus cronjob management", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-telegram": { + "description": "Telegram bot toolset - full access for personal use (terminal has safety checks)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-discord": { + "description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-whatsapp": { + "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-slack": { + "description": "Slack bot toolset - full access for workspace use (terminal has safety checks)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-signal": { + "description": "Signal bot toolset - encrypted messaging platform (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-homeassistant": { + "description": "Home Assistant bot toolset - smart home event monitoring and control", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + + "hermes-gateway": { + "description": "Gateway toolset - union of all messaging platform tools", + "tools": [], + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"] + } +} + + + +def get_toolset(name: str) -> Optional[Dict[str, Any]]: + """ + Get a toolset definition by name. + + Args: + name (str): Name of the toolset + + Returns: + Dict: Toolset definition with description, tools, and includes + None: If toolset not found + """ + # Return toolset definition + return TOOLSETS.get(name) + + +def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]: + """ + Recursively resolve a toolset to get all tool names. + + This function handles toolset composition by recursively resolving + included toolsets and combining all tools. + + Args: + name (str): Name of the toolset to resolve + visited (Set[str]): Set of already visited toolsets (for cycle detection) + + Returns: + List[str]: List of all tool names in the toolset + """ + if visited is None: + visited = set() + + # Special aliases that represent all tools across every toolset + # This ensures future toolsets are automatically included without changes. + if name in {"all", "*"}: + all_tools: Set[str] = set() + for toolset_name in get_toolset_names(): + # Use a fresh visited set per branch to avoid cross-branch contamination + resolved = resolve_toolset(toolset_name, visited.copy()) + all_tools.update(resolved) + return list(all_tools) + + # Check for cycles + if name in visited: + print(f"⚠️ Circular dependency detected in toolset '{name}'") + return [] + + visited.add(name) + + # Get toolset definition + toolset = TOOLSETS.get(name) + if not toolset: + return [] + + # Collect direct tools + tools = set(toolset.get("tools", [])) + + # Recursively resolve included toolsets + for included_name in toolset.get("includes", []): + included_tools = resolve_toolset(included_name, visited.copy()) + tools.update(included_tools) + + return list(tools) + + +def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]: + """ + Resolve multiple toolsets and combine their tools. + + Args: + toolset_names (List[str]): List of toolset names to resolve + + Returns: + List[str]: Combined list of all tool names (deduplicated) + """ + all_tools = set() + + for name in toolset_names: + tools = resolve_toolset(name) + all_tools.update(tools) + + return list(all_tools) + + +def get_all_toolsets() -> Dict[str, Dict[str, Any]]: + """ + Get all available toolsets with their definitions. + + Returns: + Dict: All toolset definitions + """ + return TOOLSETS.copy() + + +def get_toolset_names() -> List[str]: + """ + Get names of all available toolsets (excluding aliases). + + Returns: + List[str]: List of toolset names + """ + return list(TOOLSETS.keys()) + + + + +def validate_toolset(name: str) -> bool: + """ + Check if a toolset name is valid. + + Args: + name (str): Toolset name to validate + + Returns: + bool: True if valid, False otherwise + """ + # Accept special alias names for convenience + if name in {"all", "*"}: + return True + return name in TOOLSETS + + +def create_custom_toolset( + name: str, + description: str, + tools: List[str] = None, + includes: List[str] = None +) -> None: + """ + Create a custom toolset at runtime. + + Args: + name (str): Name for the new toolset + description (str): Description of the toolset + tools (List[str]): Direct tools to include + includes (List[str]): Other toolsets to include + """ + TOOLSETS[name] = { + "description": description, + "tools": tools or [], + "includes": includes or [] + } + + + + +def get_toolset_info(name: str) -> Dict[str, Any]: + """ + Get detailed information about a toolset including resolved tools. + + Args: + name (str): Toolset name + + Returns: + Dict: Detailed toolset information + """ + toolset = get_toolset(name) + if not toolset: + return None + + resolved_tools = resolve_toolset(name) + + return { + "name": name, + "description": toolset["description"], + "direct_tools": toolset["tools"], + "includes": toolset["includes"], + "resolved_tools": resolved_tools, + "tool_count": len(resolved_tools), + "is_composite": len(toolset["includes"]) > 0 + } + + +def print_toolset_tree(name: str, indent: int = 0) -> None: + """ + Print a tree view of a toolset and its composition. + + Args: + name (str): Toolset name + indent (int): Current indentation level + """ + prefix = " " * indent + toolset = get_toolset(name) + + if not toolset: + print(f"{prefix}❌ Unknown toolset: {name}") + return + + # Print toolset name and description + print(f"{prefix}📦 {name}: {toolset['description']}") + + # Print direct tools + if toolset["tools"]: + print(f"{prefix} 🔧 Tools: {', '.join(toolset['tools'])}") + + # Print included toolsets + if toolset["includes"]: + print(f"{prefix} 📂 Includes:") + for included in toolset["includes"]: + print_toolset_tree(included, indent + 2) + + +if __name__ == "__main__": + print("Toolsets System Demo") + print("=" * 60) + + print("\nAvailable Toolsets:") + print("-" * 40) + for name, toolset in get_all_toolsets().items(): + info = get_toolset_info(name) + composite = "[composite]" if info["is_composite"] else "[leaf]" + print(f" {composite} {name:20} - {toolset['description']}") + print(f" Tools: {len(info['resolved_tools'])} total") + + print("\nToolset Resolution Examples:") + print("-" * 40) + for name in ["web", "terminal", "safe", "debugging"]: + tools = resolve_toolset(name) + print(f"\n {name}:") + print(f" Resolved to {len(tools)} tools: {', '.join(sorted(tools))}") + + print("\nMultiple Toolset Resolution:") + print("-" * 40) + combined = resolve_multiple_toolsets(["web", "vision", "terminal"]) + print(f" Combining ['web', 'vision', 'terminal']:") + print(f" Result: {', '.join(sorted(combined))}") + + print("\nCustom Toolset Creation:") + print("-" * 40) + create_custom_toolset( + name="my_custom", + description="My custom toolset for specific tasks", + tools=["web_search"], + includes=["terminal", "vision"] + ) + custom_info = get_toolset_info("my_custom") + print(f" Created 'my_custom' toolset:") + print(f" Description: {custom_info['description']}") + print(f" Resolved tools: {', '.join(custom_info['resolved_tools'])}") diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 27e9fe3e..26a0683e 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -110,10 +110,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `SIGNAL_HTTP_URL` | signal-cli daemon HTTP endpoint (e.g., `http://127.0.0.1:8080`) | | `SIGNAL_ACCOUNT` | Bot phone number in E.164 format (e.g., `+15551234567`) | | `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs | -| `SIGNAL_DM_POLICY` | DM access: `pairing` (default), `allowlist`, or `open` | -| `SIGNAL_GROUP_POLICY` | Group access: `disabled` (default), `allowlist`, or `open` | -| `SIGNAL_GROUP_ALLOWED_USERS` | Comma-separated group IDs (for `allowlist` group policy) | -| `SIGNAL_DEBUG` | Enable Signal debug logging (`true`/`false`) | +| `SIGNAL_GROUP_ALLOWED_USERS` | Comma-separated group IDs, or `*` for all groups (omit to disable groups) | | `MESSAGING_CWD` | Working directory for terminal in messaging (default: `~`) | | `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms | | `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlist (`true`/`false`, default: `false`) | diff --git a/website/docs/user-guide/messaging/signal.md b/website/docs/user-guide/messaging/signal.md index 41a40926..dae1e6ee 100644 --- a/website/docs/user-guide/messaging/signal.md +++ b/website/docs/user-guide/messaging/signal.md @@ -115,16 +115,12 @@ Add to `~/.hermes/.env`: SIGNAL_HTTP_URL=http://127.0.0.1:8080 SIGNAL_ACCOUNT=+1234567890 -# Security (at least one is recommended) +# Security (recommended) SIGNAL_ALLOWED_USERS=+1234567890,+0987654321 # Comma-separated E.164 numbers or UUIDs -SIGNAL_DM_POLICY=pairing # pairing | allowlist | open -SIGNAL_GROUP_POLICY=disabled # disabled | allowlist | open # Optional -SIGNAL_GROUP_ALLOWED_USERS=groupId1,groupId2 # For group_policy=allowlist +SIGNAL_GROUP_ALLOWED_USERS=groupId1,groupId2 # Enable groups (omit to disable, * for all) SIGNAL_HOME_CHANNEL=+1234567890 # Default delivery target for cron jobs -SIGNAL_IGNORE_STORIES=true # Ignore Signal story messages -SIGNAL_DEBUG=false # Enable verbose Signal debug logging ``` Then start the gateway: @@ -136,23 +132,25 @@ hermes gateway install # Install as a system service --- -## Access Policies +## Access Control -### DM Policy +### DM Access -| Policy | Behavior | -|--------|----------| -| `pairing` (default) | Unknown users get a one-time pairing code. You approve via `hermes pairing approve signal CODE`. | -| `allowlist` | Only users in `SIGNAL_ALLOWED_USERS` can message. Others are silently ignored. | -| `open` | Anyone can message the bot. Use with caution — the bot has terminal access. | +DM access follows the same pattern as all other Hermes platforms: -### Group Policy +1. **`SIGNAL_ALLOWED_USERS` set** → only those users can message +2. **No allowlist set** → unknown users get a DM pairing code (approve via `hermes pairing approve signal CODE`) +3. **`SIGNAL_ALLOW_ALL_USERS=true`** → anyone can message (use with caution) -| Policy | Behavior | -|--------|----------| -| `disabled` (default) | All group messages are ignored. The bot only responds to DMs. | -| `allowlist` | Only groups in `SIGNAL_GROUP_ALLOWED_USERS` are monitored. | -| `open` | The bot responds in any group it's a member of. | +### Group Access + +Group access is controlled by the `SIGNAL_GROUP_ALLOWED_USERS` env var: + +| Configuration | Behavior | +|---------------|----------| +| Not set (default) | All group messages are ignored. The bot only responds to DMs. | +| Set with group IDs | Only listed groups are monitored (e.g., `groupId1,groupId2`). | +| Set to `*` | The bot responds in any group it's a member of. | --- @@ -221,10 +219,5 @@ The adapter monitors the SSE connection and automatically reconnects if: | `SIGNAL_HTTP_URL` | Yes | — | signal-cli HTTP endpoint | | `SIGNAL_ACCOUNT` | Yes | — | Bot phone number (E.164) | | `SIGNAL_ALLOWED_USERS` | No | — | Comma-separated phone numbers/UUIDs | -| `SIGNAL_ALLOW_ALL_USERS` | No | `false` | Allow all users (dangerous) | -| `SIGNAL_DM_POLICY` | No | `pairing` | DM access policy | -| `SIGNAL_GROUP_POLICY` | No | `disabled` | Group message policy | -| `SIGNAL_GROUP_ALLOWED_USERS` | No | — | Allowed group IDs | -| `SIGNAL_HOME_CHANNEL` | No | — | Default delivery target | -| `SIGNAL_IGNORE_STORIES` | No | `true` | Ignore story messages | -| `SIGNAL_DEBUG` | No | `false` | Debug logging (Signal module only) | +| `SIGNAL_GROUP_ALLOWED_USERS` | No | — | Group IDs to monitor, or `*` for all (omit to disable groups) | +| `SIGNAL_HOME_CHANNEL` | No | — | Default delivery target for cron jobs |