fix: Signal adapter parity pass — integration gaps, clawdbot features, env var simplification

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.
This commit is contained in:
teknium1 2026-03-08 21:00:21 -07:00
parent 0c4cff352a
commit b7d6eae64c
14 changed files with 645 additions and 621 deletions

View file

@ -396,7 +396,7 @@ DISCORD_ALLOWED_USERS=123456789012345678 # Comma-separated user IDs
# Signal # Signal
SIGNAL_HTTP_URL=http://127.0.0.1:8080 # signal-cli daemon URL SIGNAL_HTTP_URL=http://127.0.0.1:8080 # signal-cli daemon URL
SIGNAL_ACCOUNT=+1234567890 # Bot phone number (E.164) 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 # Agent Behavior
HERMES_MAX_ITERATIONS=90 # Max tool-calling iterations (default: 90) HERMES_MAX_ITERATIONS=90 # Max tool-calling iterations (default: 90)

View file

@ -122,6 +122,15 @@ PLATFORM_HINTS = {
"attachments, audio as file attachments. You can also include image URLs " "attachments, audio as file attachments. You can also include image URLs "
"in markdown format ![alt](url) and they will be uploaded as attachments." "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": ( "cli": (
"You are a CLI AI Agent. Try not to use markdown but simple text " "You are a CLI AI Agent. Try not to use markdown but simple text "
"renderable inside a terminal." "renderable inside a terminal."

View file

@ -98,6 +98,7 @@ def _deliver_result(job: dict, content: str) -> None:
"discord": Platform.DISCORD, "discord": Platform.DISCORD,
"slack": Platform.SLACK, "slack": Platform.SLACK,
"whatsapp": Platform.WHATSAPP, "whatsapp": Platform.WHATSAPP,
"signal": Platform.SIGNAL,
} }
platform = platform_map.get(platform_name.lower()) platform = platform_map.get(platform_name.lower())
if not platform: if not platform:

View file

@ -40,8 +40,8 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
except Exception as e: except Exception as e:
logger.warning("Channel directory: failed to build %s: %s", platform.value, e) logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
# Telegram & WhatsApp can't enumerate chats -- pull from session history # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history
for plat_name in ("telegram", "whatsapp"): for plat_name in ("telegram", "whatsapp", "signal"):
if plat_name not in platforms: if plat_name not in platforms:
platforms[plat_name] = _build_from_sessions(plat_name) platforms[plat_name] = _build_from_sessions(plat_name)

View file

@ -399,8 +399,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.SIGNAL].extra.update({ config.platforms[Platform.SIGNAL].extra.update({
"http_url": signal_url, "http_url": signal_url,
"account": signal_account, "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"), "ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in ("true", "1", "yes"),
}) })
signal_home = os.getenv("SIGNAL_HOME_CHANNEL") signal_home = os.getenv("SIGNAL_HOME_CHANNEL")

View file

@ -16,6 +16,7 @@ import base64
import json import json
import logging import logging
import os import os
import random
import re import re
import time import time
from datetime import datetime, timezone 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") 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: def check_signal_requirements() -> bool:
"""Check if Signal is configured (has URL and account).""" """Check if Signal is configured (has URL and account)."""
return bool(os.getenv("SIGNAL_HTTP_URL") and os.getenv("SIGNAL_ACCOUNT")) return bool(os.getenv("SIGNAL_HTTP_URL") and os.getenv("SIGNAL_ACCOUNT"))
@ -123,13 +145,9 @@ class SignalAdapter(BasePlatformAdapter):
extra = config.extra or {} extra = config.extra or {}
self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/") self.http_url = extra.get("http_url", "http://127.0.0.1:8080").rstrip("/")
self.account = extra.get("account", "") 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) self.ignore_stories = extra.get("ignore_stories", True)
# Parse allowlists # Parse allowlists — group policy is derived from presence of group allowlist
allowed_str = os.getenv("SIGNAL_ALLOWED_USERS", "")
self.allowed_users = set(_parse_comma_list(allowed_str))
group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "") group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "")
self.group_allow_from = set(_parse_comma_list(group_allowed_str)) 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._last_sse_activity = 0.0
self._sse_response: Optional[httpx.Response] = None self._sse_response: Optional[httpx.Response] = None
# Pairing store (lazy import to avoid circular deps) # Normalize account for self-message filtering
from gateway.pairing import PairingStore self._account_normalized = self.account.strip()
self.pairing_store = PairingStore()
# Debug logging (scoped to this module, NOT root logger) logger.info("Signal adapter initialized: url=%s account=%s groups=%s",
if os.getenv("SIGNAL_DEBUG", "").lower() in ("true", "1", "yes"): self.http_url, _redact_phone(self.account),
logger.setLevel(logging.DEBUG) "enabled" if self.group_allow_from else "disabled")
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)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Lifecycle # Lifecycle
@ -270,7 +284,9 @@ class SignalAdapter(BasePlatformAdapter):
logger.warning("Signal SSE: error: %s (reconnecting in %.0fs)", e, backoff) logger.warning("Signal SSE: error: %s (reconnecting in %.0fs)", e, backoff)
if self._running: 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) backoff = min(backoff * 2, SSE_RETRY_DELAY_MAX)
self._sse_response = None self._sse_response = None
@ -323,6 +339,11 @@ class SignalAdapter(BasePlatformAdapter):
# Unwrap nested envelope if present # Unwrap nested envelope if present
envelope_data = envelope.get("envelope", envelope) 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 # Extract sender info
sender = ( sender = (
envelope_data.get("sourceNumber") envelope_data.get("sourceNumber")
@ -336,12 +357,20 @@ class SignalAdapter(BasePlatformAdapter):
logger.debug("Signal: ignoring envelope with no sender") logger.debug("Signal: ignoring envelope with no sender")
return return
# Self-message filtering — prevent reply loops
if self._account_normalized and sender == self._account_normalized:
return
# Filter stories # Filter stories
if self.ignore_stories and envelope_data.get("storyMessage"): if self.ignore_stories and envelope_data.get("storyMessage"):
return return
# Get data message (skip receipts, typing indicators, etc.) # Get data message — also check editMessage (edited messages contain
data_message = envelope_data.get("dataMessage") # their updated dataMessage inside editMessage.dataMessage)
data_message = (
envelope_data.get("dataMessage")
or (envelope_data.get("editMessage") or {}).get("dataMessage")
)
if not data_message: if not data_message:
return return
@ -350,29 +379,28 @@ class SignalAdapter(BasePlatformAdapter):
group_id = group_info.get("groupId") if group_info else None group_id = group_info.get("groupId") if group_info else None
is_group = bool(group_id) is_group = bool(group_id)
# Authorization check — delegated to run.py's _is_user_authorized() # Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS:
# for DM allowlists. We only do group policy filtering here since # - No env var set → groups disabled (default safe behavior)
# that's Signal-specific and not in the base auth system. # - 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 is_group:
if self.group_policy == "disabled": if not self.group_allow_from:
logger.debug("Signal: ignoring group message (group_policy=disabled)") logger.debug("Signal: ignoring group message (no SIGNAL_GROUP_ALLOWED_USERS)")
return return
if self.group_policy == "allowlist":
if "*" not in self.group_allow_from and group_id not in self.group_allow_from: 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 "?") logger.debug("Signal: group %s not in allowlist", group_id[:8] if group_id else "?")
return 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 # Build chat info
chat_id = sender if not is_group else f"group:{group_id}" chat_id = sender if not is_group else f"group:{group_id}"
chat_type = "group" if is_group else "dm" chat_type = "group" if is_group else "dm"
# Extract text # Extract text and render mentions
text = data_message.get("message", "") text = data_message.get("message", "")
mentions = data_message.get("mentions", [])
if text and mentions:
text = _render_mentions(text, mentions)
# Process attachments # Process attachments
attachments_data = data_message.get("attachments", []) attachments_data = data_message.get("attachments", [])

View file

@ -757,42 +757,25 @@ def _setup_signal():
save_env_value("SIGNAL_ALLOWED_USERS", allowed) save_env_value("SIGNAL_ALLOWED_USERS", allowed)
# DM policy # Group messaging
print() print()
policies = ["pairing (default — new users get a pairing code to approve)", if prompt_yes_no(" Enable group messaging? (disabled by default for security)", False):
"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() print()
group_policies = ["disabled (default — ignore group messages)", print_info(" Enter group IDs to allow, or * for all groups.")
"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":
print()
print_info(" Enter group IDs to allow (comma-separated).")
existing_groups = get_env_value("SIGNAL_GROUP_ALLOWED_USERS") or "" existing_groups = get_env_value("SIGNAL_GROUP_ALLOWED_USERS") or ""
try: 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): except (EOFError, KeyboardInterrupt):
print("\n Setup cancelled.") print("\n Setup cancelled.")
return return
if groups:
save_env_value("SIGNAL_GROUP_ALLOWED_USERS", groups) save_env_value("SIGNAL_GROUP_ALLOWED_USERS", groups)
print() print()
print_success("Signal configured!") print_success("Signal configured!")
print_info(f" URL: {url}") print_info(f" URL: {url}")
print_info(f" Account: {account}") print_info(f" Account: {account}")
print_info(f" DM policy: {dm_policy}") print_info(f" DM auth: via SIGNAL_ALLOWED_USERS + DM pairing")
print_info(f" Group policy: {group_policy}") print_info(f" Groups: {'enabled' if get_env_value('SIGNAL_GROUP_ALLOWED_USERS') else 'disabled'}")
def gateway_setup(): def gateway_setup():

View file

@ -206,6 +206,8 @@ def show_status(args):
"Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"), "Telegram": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL"),
"Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"), "Discord": ("DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL"),
"WhatsApp": ("WHATSAPP_ENABLED", None), "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(): for name, (token_var, home_var) in platforms.items():

View file

@ -23,8 +23,6 @@ class TestSignalConfigLoading:
def test_apply_env_overrides_signal(self, monkeypatch): def test_apply_env_overrides_signal(self, monkeypatch):
monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090")
monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567") 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 from gateway.config import GatewayConfig, _apply_env_overrides
config = GatewayConfig() config = GatewayConfig()
@ -35,8 +33,6 @@ class TestSignalConfigLoading:
assert sc.enabled is True assert sc.enabled is True
assert sc.extra["http_url"] == "http://localhost:9090" assert sc.extra["http_url"] == "http://localhost:9090"
assert sc.extra["account"] == "+15551234567" 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): def test_signal_not_loaded_without_both_vars(self, monkeypatch):
monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090") monkeypatch.setenv("SIGNAL_HTTP_URL", "http://localhost:9090")
@ -71,49 +67,44 @@ class TestSignalAdapterInit:
config.extra = { config.extra = {
"http_url": "http://localhost:8080", "http_url": "http://localhost:8080",
"account": "+15551234567", "account": "+15551234567",
"dm_policy": "pairing",
"group_policy": "disabled",
**extra, **extra,
} }
return config return config
def test_init_parses_config(self, monkeypatch): def test_init_parses_config(self, monkeypatch):
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "+15559876543,+15551111111")
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "group123,group456") monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "group123,group456")
monkeypatch.delenv("SIGNAL_DEBUG", raising=False)
from gateway.platforms.signal import SignalAdapter from gateway.platforms.signal import SignalAdapter
adapter = SignalAdapter(self._make_config()) adapter = SignalAdapter(self._make_config())
assert adapter.http_url == "http://localhost:8080" assert adapter.http_url == "http://localhost:8080"
assert adapter.account == "+15551234567" 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 assert "group123" in adapter.group_allow_from
def test_init_empty_allowlist(self, monkeypatch): def test_init_empty_allowlist(self, monkeypatch):
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "")
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "")
monkeypatch.delenv("SIGNAL_DEBUG", raising=False)
from gateway.platforms.signal import SignalAdapter from gateway.platforms.signal import SignalAdapter
adapter = SignalAdapter(self._make_config()) adapter = SignalAdapter(self._make_config())
assert len(adapter.allowed_users) == 0
assert len(adapter.group_allow_from) == 0 assert len(adapter.group_allow_from) == 0
def test_init_strips_trailing_slash(self, monkeypatch): def test_init_strips_trailing_slash(self, monkeypatch):
monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "")
monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "")
monkeypatch.delenv("SIGNAL_DEBUG", raising=False)
from gateway.platforms.signal import SignalAdapter from gateway.platforms.signal import SignalAdapter
adapter = SignalAdapter(self._make_config(http_url="http://localhost:8080/")) adapter = SignalAdapter(self._make_config(http_url="http://localhost:8080/"))
assert adapter.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: class TestSignalHelpers:
def test_redact_phone_long(self): def test_redact_phone_long(self):
@ -177,6 +168,20 @@ class TestSignalHelpers:
monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567") monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567")
assert check_signal_requirements() is True 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): def test_check_requirements_missing(self, monkeypatch):
from gateway.platforms.signal import check_signal_requirements from gateway.platforms.signal import check_signal_requirements
monkeypatch.delenv("SIGNAL_HTTP_URL", raising=False) monkeypatch.delenv("SIGNAL_HTTP_URL", raising=False)

View file

@ -102,7 +102,9 @@ def schedule_cronjob(
- "local": Save to local files only (~/.hermes/cron/output/) - "local": Save to local files only (~/.hermes/cron/output/)
- "telegram": Send to Telegram home channel - "telegram": Send to Telegram home channel
- "discord": Send to Discord home channel - "discord": Send to Discord home channel
- "signal": Send to Signal home channel
- "telegram:123456": Send to specific chat ID - "telegram:123456": Send to specific chat ID
- "signal:+15551234567": Send to specific Signal number
Returns: Returns:
JSON with job_id, next_run time, and confirmation JSON with job_id, next_run time, and confirmation
@ -216,7 +218,7 @@ Use for: reminders, periodic checks, scheduled reports, automated maintenance.""
}, },
"deliver": { "deliver": {
"type": "string", "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"] "required": ["prompt", "schedule"]

View file

@ -33,7 +33,7 @@ SEND_MESSAGE_SCHEMA = {
}, },
"target": { "target": {
"type": "string", "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": { "message": {
"type": "string", "type": "string",

View file

@ -255,6 +255,12 @@ TOOLSETS = {
"includes": [] "includes": []
}, },
"hermes-signal": {
"description": "Signal bot toolset - encrypted messaging platform (full access)",
"tools": _HERMES_CORE_TOOLS,
"includes": []
},
"hermes-homeassistant": { "hermes-homeassistant": {
"description": "Home Assistant bot toolset - smart home event monitoring and control", "description": "Home Assistant bot toolset - smart home event monitoring and control",
"tools": _HERMES_CORE_TOOLS, "tools": _HERMES_CORE_TOOLS,
@ -264,7 +270,7 @@ TOOLSETS = {
"hermes-gateway": { "hermes-gateway": {
"description": "Gateway toolset - union of all messaging platform tools", "description": "Gateway toolset - union of all messaging platform tools",
"tools": [], "tools": [],
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-homeassistant"] "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"]
} }
} }

View file

@ -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_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_ACCOUNT` | Bot phone number in E.164 format (e.g., `+15551234567`) |
| `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs | | `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs |
| `SIGNAL_DM_POLICY` | DM access: `pairing` (default), `allowlist`, or `open` | | `SIGNAL_GROUP_ALLOWED_USERS` | Comma-separated group IDs, or `*` for all groups (omit to disable groups) |
| `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`) |
| `MESSAGING_CWD` | Working directory for terminal in messaging (default: `~`) | | `MESSAGING_CWD` | Working directory for terminal in messaging (default: `~`) |
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms | | `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlist (`true`/`false`, default: `false`) | | `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlist (`true`/`false`, default: `false`) |

View file

@ -115,16 +115,12 @@ Add to `~/.hermes/.env`:
SIGNAL_HTTP_URL=http://127.0.0.1:8080 SIGNAL_HTTP_URL=http://127.0.0.1:8080
SIGNAL_ACCOUNT=+1234567890 SIGNAL_ACCOUNT=+1234567890
# Security (at least one is recommended) # Security (recommended)
SIGNAL_ALLOWED_USERS=+1234567890,+0987654321 # Comma-separated E.164 numbers or UUIDs 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 # 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_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: 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 | DM access follows the same pattern as all other Hermes platforms:
|--------|----------|
| `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. |
### 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 | ### Group Access
|--------|----------|
| `disabled` (default) | All group messages are ignored. The bot only responds to DMs. | Group access is controlled by the `SIGNAL_GROUP_ALLOWED_USERS` env var:
| `allowlist` | Only groups in `SIGNAL_GROUP_ALLOWED_USERS` are monitored. |
| `open` | The bot responds in any group it's a member of. | | 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_HTTP_URL` | Yes | — | signal-cli HTTP endpoint |
| `SIGNAL_ACCOUNT` | Yes | — | Bot phone number (E.164) | | `SIGNAL_ACCOUNT` | Yes | — | Bot phone number (E.164) |
| `SIGNAL_ALLOWED_USERS` | No | — | Comma-separated phone numbers/UUIDs | | `SIGNAL_ALLOWED_USERS` | No | — | Comma-separated phone numbers/UUIDs |
| `SIGNAL_ALLOW_ALL_USERS` | No | `false` | Allow all users (dangerous) | | `SIGNAL_GROUP_ALLOWED_USERS` | No | — | Group IDs to monitor, or `*` for all (omit to disable groups) |
| `SIGNAL_DM_POLICY` | No | `pairing` | DM access policy | | `SIGNAL_HOME_CHANNEL` | No | — | Default delivery target for cron jobs |
| `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) |