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:
@@ -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)
|
||||
|
||||
@@ -122,6 +122,15 @@ PLATFORM_HINTS = {
|
||||
"attachments, audio as file attachments. You can also include image URLs "
|
||||
"in markdown format  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  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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
1028
toolsets.py
1028
toolsets.py
File diff suppressed because it is too large
Load Diff
@@ -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`) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user