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_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)

View File

@@ -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."

View File

@@ -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:

View File

@@ -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)

View File

@@ -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")

View File

@@ -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", [])

View File

@@ -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():

View File

@@ -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():

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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",

File diff suppressed because it is too large Load Diff

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_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`) |

View File

@@ -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 |