diff --git a/plugins/platforms/simplex/__init__.py b/plugins/platforms/simplex/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/simplex/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py new file mode 100644 index 00000000000..b568f29bbb5 --- /dev/null +++ b/plugins/platforms/simplex/adapter.py @@ -0,0 +1,746 @@ +"""SimpleX Chat platform adapter (Hermes plugin). + +Connects to a simplex-chat daemon running in WebSocket mode. +Inbound messages arrive via a persistent WebSocket connection. +Outbound messages use the same WebSocket with JSON commands. + +This adapter ships as a Hermes platform plugin under +``plugins/platforms/simplex/``. The Hermes plugin loader scans the +directory at startup, calls ``register(ctx)``, and the platform +becomes available to ``gateway/run.py`` and ``tools/send_message_tool`` +through the registry — no edits to core files are required. + +SimpleX chat daemon setup: + simplex-chat -p 5225 # start daemon on port 5225 + # or via Docker: + # docker run -p 5225:5225 simplexchat/simplex-chat-cli -p 5225 + +Required environment variables: + SIMPLEX_WS_URL WebSocket URL of the daemon + (default: ws://127.0.0.1:5225) + +Optional environment variables: + SIMPLEX_ALLOWED_USERS Comma-separated contact IDs (allowlist) + SIMPLEX_ALLOW_ALL_USERS Set 'true' to allow all contacts + SIMPLEX_HOME_CHANNEL Default contact/group ID for cron delivery + SIMPLEX_HOME_CHANNEL_NAME Human label for the home channel + +The ``websockets`` Python package is imported lazily — the plugin is +discoverable and `hermes setup` can describe it even when websockets is +not installed. ``check_requirements()`` returns False until the package +is present, so the gateway will not attempt to instantiate the adapter. +""" + +import asyncio +import json +import logging +import os +import random +import time +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +# Lazy import: BasePlatformAdapter and friends live in the main repo. +# Imported at module top because they're stdlib-only inside Hermes — no +# external dependency that would block the plugin from loading. +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_image_from_bytes, + cache_audio_from_bytes, + cache_document_from_bytes, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +MAX_MESSAGE_LENGTH = 16_000 # SimpleX has no hard limit; keep chunking sane +TYPING_INTERVAL = 10.0 +WS_RETRY_DELAY_INITIAL = 2.0 +WS_RETRY_DELAY_MAX = 60.0 +HEALTH_CHECK_INTERVAL = 30.0 +HEALTH_CHECK_STALE_THRESHOLD = 120.0 + +# Correlation ID prefix for requests we send so we can ignore our own echoes. +_CORR_PREFIX = "hermes-" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _parse_comma_list(value: str) -> List[str]: + """Split a comma-separated string into a stripped list.""" + return [v.strip() for v in value.split(",") if v.strip()] + + +def _guess_extension(data: bytes) -> str: + """Guess file extension from magic bytes.""" + if data[:4] == b"\x89PNG": + return ".png" + if data[:2] == b"\xff\xd8": + return ".jpg" + if data[:4] == b"GIF8": + return ".gif" + if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return ".webp" + if data[:4] == b"%PDF": + return ".pdf" + if len(data) >= 8 and data[4:8] == b"ftyp": + return ".mp4" + if data[:4] == b"OggS": + return ".ogg" + if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0: + return ".mp3" + return ".bin" + + +def _is_image_ext(ext: str) -> bool: + return ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp") + + +def _is_audio_ext(ext: str) -> bool: + return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac") + + +# --------------------------------------------------------------------------- +# SimpleX Adapter +# --------------------------------------------------------------------------- + +class SimplexAdapter(BasePlatformAdapter): + """SimpleX Chat adapter using the simplex-chat daemon WebSocket API. + + Instantiated by the ``adapter_factory`` passed to + ``ctx.register_platform()`` in :func:`register`. + """ + + def __init__(self, config: PlatformConfig, **kwargs): + platform = Platform("simplex") + super().__init__(config=config, platform=platform) + + extra = getattr(config, "extra", {}) or {} + self.ws_url = extra.get("ws_url", "ws://127.0.0.1:5225").rstrip("/") + + # Running state + self._ws = None # websockets connection + self._ws_task: Optional[asyncio.Task] = None + self._health_task: Optional[asyncio.Task] = None + self._typing_tasks: Dict[str, asyncio.Task] = {} + self._running = False + self._last_ws_activity = 0.0 + + # Track sent correlation IDs to filter echoes + self._pending_corr_ids: set = set() + self._max_pending_corr = 200 + + logger.info("SimpleX adapter initialized: url=%s", self.ws_url) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def connect(self) -> bool: + """Connect to the simplex-chat daemon and start the WebSocket listener.""" + try: + import websockets # noqa: F401 + except ImportError: + logger.error( + "SimpleX: 'websockets' package not installed. " + "Run: pip install websockets" + ) + return False + + if not self.ws_url: + logger.error("SimpleX: SIMPLEX_WS_URL is required") + return False + + # Quick connectivity check — try to open and immediately close + try: + import websockets as _wsclient + async with _wsclient.connect(self.ws_url, open_timeout=10): + pass + except Exception as e: + logger.error("SimpleX: cannot reach daemon at %s: %s", self.ws_url, e) + return False + + self._running = True + self._last_ws_activity = time.time() + self._ws_task = asyncio.create_task(self._ws_listener()) + self._health_task = asyncio.create_task(self._health_monitor()) + + logger.info("SimpleX: connected to %s", self.ws_url) + return True + + async def disconnect(self) -> None: + """Stop WebSocket listener and clean up.""" + self._running = False + + if self._ws_task: + self._ws_task.cancel() + try: + await self._ws_task + except asyncio.CancelledError: + pass + + if self._health_task: + self._health_task.cancel() + try: + await self._health_task + except asyncio.CancelledError: + pass + + for task in self._typing_tasks.values(): + task.cancel() + self._typing_tasks.clear() + + if self._ws: + try: + await self._ws.close() + except Exception: + pass + self._ws = None + + logger.info("SimpleX: disconnected") + + # ------------------------------------------------------------------ + # WebSocket listener + # ------------------------------------------------------------------ + + async def _ws_listener(self) -> None: + """Maintain a persistent WebSocket connection to the daemon.""" + import websockets as _wsclient + import websockets as _wsexc + + backoff = WS_RETRY_DELAY_INITIAL + + while self._running: + try: + logger.debug("SimpleX WS: connecting to %s", self.ws_url) + async with _wsclient.connect( + self.ws_url, + ping_interval=20, + ping_timeout=20, + ) as ws: + self._ws = ws + backoff = WS_RETRY_DELAY_INITIAL + self._last_ws_activity = time.time() + logger.info("SimpleX WS: connected") + + async for raw in ws: + if not self._running: + break + self._last_ws_activity = time.time() + try: + msg = json.loads(raw) + await self._handle_event(msg) + except json.JSONDecodeError: + logger.debug("SimpleX WS: invalid JSON: %.100s", raw) + except Exception: + logger.exception("SimpleX WS: error handling event") + + except asyncio.CancelledError: + break + except _wsexc.WebSocketException as e: + if self._running: + logger.warning( + "SimpleX WS: error: %s (reconnecting in %.0fs)", e, backoff + ) + except Exception as e: + if self._running: + logger.warning( + "SimpleX WS: unexpected error: %s (reconnecting in %.0fs)", + e, backoff, + ) + finally: + self._ws = None + + if self._running: + jitter = backoff * 0.2 * random.random() + await asyncio.sleep(backoff + jitter) + backoff = min(backoff * 2, WS_RETRY_DELAY_MAX) + + # ------------------------------------------------------------------ + # Health monitor + # ------------------------------------------------------------------ + + async def _health_monitor(self) -> None: + """Force reconnect if the WebSocket has been idle too long.""" + while self._running: + await asyncio.sleep(HEALTH_CHECK_INTERVAL) + if not self._running: + break + + elapsed = time.time() - self._last_ws_activity + if elapsed > HEALTH_CHECK_STALE_THRESHOLD: + logger.warning( + "SimpleX: WS idle for %.0fs, forcing reconnect", elapsed + ) + self._last_ws_activity = time.time() + if self._ws: + try: + await self._ws.close() + except Exception: + pass + + # ------------------------------------------------------------------ + # Inbound event handling + # ------------------------------------------------------------------ + + async def _handle_event(self, event: dict) -> None: + """Dispatch a daemon event to the appropriate handler.""" + resp_type = event.get("type") or event.get("resp", {}).get("type", "") + + # Filter responses to our own commands (echoes) + corr_id = event.get("corrId", "") + if corr_id and corr_id.startswith(_CORR_PREFIX): + self._pending_corr_ids.discard(corr_id) + return + + if resp_type == "newChatItem": + await self._handle_new_chat_item(event) + elif resp_type == "newChatItems": + # Batch variant — process each item + items = event.get("chatItems") or [] + for item_wrapper in items: + await self._handle_new_chat_item(item_wrapper) + # Ignore all other event types (delivery receipts, contact updates, etc.) + + async def _handle_new_chat_item(self, wrapper: dict) -> None: + """Process a single newChatItem event into a MessageEvent.""" + # The daemon wraps the chat item differently depending on version; + # normalise both layouts. + chat_info = wrapper.get("chatInfo") or wrapper.get("chat") or {} + chat_item = wrapper.get("chatItem") or wrapper.get("item") or {} + + # Only process messages (not calls, deleted items, etc.) + item_content = chat_item.get("content") or {} + msg_content = item_content.get("msgContent") or {} + if not msg_content: + return + + # Filter out messages sent by us (direction == "snd") + meta = chat_item.get("meta") or {} + direction = (meta.get("itemStatus") or {}).get("type", "") + if direction in ("sndSent", "sndSentDirect", "sndSentViaProxy", "sndNew"): + return + + # Determine chat type and IDs + chat_type_raw = chat_info.get("type", "") + is_group = chat_type_raw in ("group", "groupInfo") + + if is_group: + group_info = chat_info.get("groupInfo") or chat_info.get("group") or {} + group_id = str(group_info.get("groupId") or group_info.get("id") or "") + group_name = group_info.get("displayName") or group_info.get("groupProfile", {}).get("displayName", "") + chat_id = f"group:{group_id}" if group_id else "" + chat_name = group_name + else: + contact_info = chat_info.get("contact") or {} + contact_id = str(contact_info.get("contactId") or contact_info.get("id") or "") + contact_name = ( + contact_info.get("displayName") + or contact_info.get("localDisplayName") + or contact_id + ) + chat_id = contact_id + chat_name = contact_name + + if not chat_id: + logger.debug("SimpleX: ignoring event with no chat_id") + return + + # Sender — for groups the message includes a chatItemMember sub-object + member = chat_item.get("chatItemMember") or {} + if is_group and member: + sender_id = str(member.get("memberId") or member.get("id") or chat_id) + sender_name = ( + member.get("displayName") + or member.get("localDisplayName") + or sender_id + ) + else: + sender_id = chat_id + sender_name = chat_name + + # Extract text + text = msg_content.get("text") or "" + + # Media attachments + media_urls: List[str] = [] + media_types: List[str] = [] + file_info = chat_item.get("file") or {} + if file_info and file_info.get("fileStatus") not in ("cancelled", "error"): + file_id = file_info.get("fileId") + file_name = file_info.get("fileName", "file") + if file_id: + try: + cached = await self._fetch_file(file_id, file_name) + if cached: + ext = cached.rsplit(".", 1)[-1] + if _is_image_ext("." + ext): + media_types.append("image/" + ext.replace("jpg", "jpeg")) + elif _is_audio_ext("." + ext): + media_types.append("audio/" + ext) + else: + media_types.append("application/octet-stream") + media_urls.append(cached) + except Exception: + logger.exception("SimpleX: failed to fetch file %s", file_id) + + # Timestamp + ts_str = meta.get("itemTs") or meta.get("createdAt") or "" + try: + timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) + except (ValueError, AttributeError): + timestamp = datetime.now(tz=timezone.utc) + + # Build source + source = self.build_source( + chat_id=chat_id, + chat_name=chat_name, + chat_type="group" if is_group else "dm", + user_id=sender_id, + user_name=sender_name, + ) + + # Message type + msg_type = MessageType.TEXT + if media_types: + if any(mt.startswith("audio/") for mt in media_types): + msg_type = MessageType.VOICE + elif any(mt.startswith("image/") for mt in media_types): + msg_type = MessageType.PHOTO + + event_obj = MessageEvent( + source=source, + text=text, + message_type=msg_type, + media_urls=media_urls, + media_types=media_types, + timestamp=timestamp, + raw_message=wrapper, + ) + + await self.handle_message(event_obj) + + async def _fetch_file(self, file_id: Any, file_name: str) -> Optional[str]: + """Ask the daemon to receive and return a file attachment.""" + # simplex-chat exposes `/api/v1/files/{fileId}` on an HTTP port + # when started with --http-port. However, the canonical WebSocket API + # does not have a direct binary download command; files are stored on + # the local filesystem after the daemon accepts them. + # + # We request acceptance first, then read from the daemon's local path. + corr_id = self._make_corr_id() + cmd = { + "corrId": corr_id, + "cmd": f"/freceive {file_id}", + } + await self._send_ws(cmd) + # The daemon will emit a chatItemUpdated event when the file lands; + # for simplicity we just wait briefly and rely on the daemon's default path. + await asyncio.sleep(2) + + # simplex-chat stores received files in ~/Downloads or a configured path. + # We try common locations. + for search_dir in ( + os.path.expanduser("~/Downloads"), + os.path.expanduser("~/.simplex/files"), + "/tmp/simplex_files", + ): + candidate = os.path.join(search_dir, file_name) + if os.path.exists(candidate): + with open(candidate, "rb") as f: + data = f.read() + ext = _guess_extension(data) + if _is_image_ext(ext): + return cache_image_from_bytes(data, ext) + elif _is_audio_ext(ext): + return cache_audio_from_bytes(data, ext) + else: + return cache_document_from_bytes(data, file_name) + return None + + # ------------------------------------------------------------------ + # Outbound messages + # ------------------------------------------------------------------ + + def _make_corr_id(self) -> str: + """Generate a unique correlation ID for a request.""" + corr_id = f"{_CORR_PREFIX}{int(time.time() * 1000)}-{random.randint(0, 9999)}" + self._pending_corr_ids.add(corr_id) + if len(self._pending_corr_ids) > self._max_pending_corr: + # Trim oldest — sets are unordered so just clear the oldest half + to_remove = list(self._pending_corr_ids)[:self._max_pending_corr // 2] + self._pending_corr_ids -= set(to_remove) + return corr_id + + async def _send_ws(self, payload: dict) -> None: + """Send a JSON payload over the WebSocket, queuing if not yet connected.""" + import websockets as _wsexc + ws = self._ws + if not ws: + logger.debug("SimpleX: WS not connected, dropping outbound command") + return + try: + await ws.send(json.dumps(payload)) + except _wsexc.ConnectionClosed: + logger.warning("SimpleX: WS closed while sending") + except Exception as e: + logger.warning("SimpleX: WS send error: %s", e) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message to a contact or group.""" + corr_id = self._make_corr_id() + + if chat_id.startswith("group:"): + group_id = chat_id[6:] + cmd_str = f"#[{group_id}] {content}" + else: + cmd_str = f"@[{chat_id}] {content}" + + payload = { + "corrId": corr_id, + "cmd": cmd_str, + } + + await self._send_ws(payload) + return SendResult(success=True) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """SimpleX does not expose a typing indicator API — no-op.""" + pass + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an image (URL) as a message with optional caption. + + SimpleX has no native ``send_image`` over the WebSocket API — file + attachments require the daemon's filesystem-backed flow which is + not driven from this adapter. Fall back to a plain text message + containing the URL and caption. + """ + text = f"{caption}\n{image_url}".strip() if caption else image_url + return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) + + async def get_chat_info(self, chat_id: str) -> dict: + """Return basic chat info.""" + if chat_id.startswith("group:"): + return {"chat_id": chat_id, "type": "group", "name": chat_id[6:]} + return {"chat_id": chat_id, "type": "dm", "name": chat_id} + + +# --------------------------------------------------------------------------- +# Plugin entry-point hooks +# --------------------------------------------------------------------------- + +def check_requirements() -> bool: + """Plugin gate: require SIMPLEX_WS_URL AND the websockets package. + + Returning False keeps the platform out of ``get_connected_platforms()`` + so the gateway never instantiates the adapter when the dependency is + missing or no daemon URL is configured. + """ + if not os.getenv("SIMPLEX_WS_URL"): + return False + try: + import websockets # noqa: F401 + except ImportError: + return False + return True + + +def validate_config(config) -> bool: + """Validate that the platform config has enough info to connect.""" + extra = getattr(config, "extra", {}) or {} + ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "") + return bool(ws_url) + + +def is_connected(config) -> bool: + """Check whether SimpleX is configured (env or config.yaml).""" + extra = getattr(config, "extra", {}) or {} + ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "") + return bool(ws_url) + + +def _env_enablement() -> dict | None: + """Seed ``PlatformConfig.extra`` from env vars during gateway config load. + + Called by the platform registry's env-enablement hook BEFORE adapter + construction, so ``gateway status`` and ``get_connected_platforms()`` + reflect env-only configuration without instantiating the WebSocket + client. Returns ``None`` when SimpleX isn't minimally configured. + + The special ``home_channel`` key in the returned dict is handled by + the core hook — it becomes a proper ``HomeChannel`` dataclass on the + ``PlatformConfig`` rather than being merged into ``extra``. + """ + ws_url = os.getenv("SIMPLEX_WS_URL", "").strip() + if not ws_url: + return None + seed: dict = {"ws_url": ws_url} + home = os.getenv("SIMPLEX_HOME_CHANNEL", "").strip() + if home: + seed["home_channel"] = { + "chat_id": home, + "name": os.getenv("SIMPLEX_HOME_CHANNEL_NAME", "").strip() or home, + } + return seed + + +async def _standalone_send( + pconfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, + media_files: Optional[List[str]] = None, + force_document: bool = False, +) -> Dict[str, Any]: + """Open an ephemeral WebSocket to the daemon, send, and close. + + Used by ``tools/send_message_tool._send_via_adapter`` when the gateway + runner is not in this process (e.g. ``hermes cron`` running as a + separate process from ``hermes gateway``). Without this hook, + ``deliver=simplex`` cron jobs fail with "No live adapter for platform". + + ``thread_id`` and ``force_document`` are accepted for signature parity + with other plugins but are not meaningful here. ``media_files`` is + accepted but only the text body is delivered — SimpleX requires the + daemon's filesystem-backed file flow which an ephemeral connection + cannot drive safely. + """ + try: + import websockets as _wsclient + except ImportError: + return {"error": "websockets not installed. Run: pip install websockets"} + + extra = getattr(pconfig, "extra", {}) or {} + ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "ws://127.0.0.1:5225") + if not ws_url: + return {"error": "SimpleX standalone send: SIMPLEX_WS_URL is required"} + + try: + if chat_id.startswith("group:"): + group_id = chat_id[6:] + cmd_str = f"#[{group_id}] {message}" + else: + cmd_str = f"@[{chat_id}] {message}" + + payload = { + "corrId": f"hermes-snd-{int(time.time() * 1000)}", + "cmd": cmd_str, + } + + async with _wsclient.connect(ws_url, open_timeout=10, close_timeout=5) as ws: + await ws.send(json.dumps(payload)) + # Give the daemon a moment to process the command before closing. + await asyncio.sleep(0.5) + + return {"success": True, "platform": "simplex", "chat_id": chat_id} + except Exception as e: + return {"error": f"SimpleX send failed: {e}"} + + +def interactive_setup() -> None: + """Minimal stdin wizard for ``hermes setup gateway`` → SimpleX. + + Prompts for the WebSocket URL and the optional allowlist / home channel. + Writes to ``~/.hermes/.env`` via ``hermes_cli.config``. + """ + print() + print("SimpleX Chat setup") + print("------------------") + print("Requirements:") + print(" 1. simplex-chat daemon running (e.g. `simplex-chat -p 5225`).") + print(" 2. Python package `websockets` installed (`pip install websockets`).") + print() + + try: + from hermes_cli.config import get_env_value, save_env_value + except ImportError: + print("hermes_cli.config not available; set SIMPLEX_* vars manually in ~/.hermes/.env") + return + + def _prompt(var: str, prompt: str, *, secret: bool = False) -> None: + existing = get_env_value(var) if callable(get_env_value) else None + suffix = " [keep current]" if existing else "" + try: + if secret: + import getpass + value = getpass.getpass(f"{prompt}{suffix}: ") + else: + value = input(f"{prompt}{suffix}: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return + if value: + save_env_value(var, value) + + _prompt("SIMPLEX_WS_URL", "Daemon WebSocket URL (default ws://127.0.0.1:5225)") + _prompt("SIMPLEX_ALLOWED_USERS", "Allowed contact IDs (comma-separated; blank=skip)") + _prompt("SIMPLEX_HOME_CHANNEL", "Home channel contact/group ID (or empty)") + print("Done. Make sure the simplex-chat daemon is running before starting the gateway.") + + +def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system at startup.""" + ctx.register_platform( + name="simplex", + label="SimpleX Chat", + adapter_factory=lambda cfg: SimplexAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=["SIMPLEX_WS_URL"], + install_hint="pip install websockets # SimpleX adapter requires the websockets package", + setup_fn=interactive_setup, + # Env-driven auto-configuration: seeds PlatformConfig.extra so + # env-only setups show up in `hermes gateway status` without + # instantiating the adapter. + env_enablement_fn=_env_enablement, + # Cron home-channel delivery support — `deliver=simplex` cron jobs + # route to SIMPLEX_HOME_CHANNEL when set. + cron_deliver_env_var="SIMPLEX_HOME_CHANNEL", + # Out-of-process cron delivery. Without this hook, deliver=simplex + # cron jobs fail with "No live adapter" when cron runs separately + # from the gateway. + standalone_sender_fn=_standalone_send, + # Auth env vars for _is_user_authorized() integration + allowed_users_env="SIMPLEX_ALLOWED_USERS", + allow_all_env="SIMPLEX_ALLOW_ALL_USERS", + # SimpleX has no hard line length; we still chunk for sanity. + max_message_length=MAX_MESSAGE_LENGTH, + # Display + emoji="🔒", + # SimpleX uses opaque contact IDs only — no phone numbers or + # email addresses to redact. + pii_safe=True, + allow_update_command=True, + # LLM guidance + platform_hint=( + "You are chatting via SimpleX Chat, a private decentralised " + "messenger. Contacts are identified by opaque internal IDs, " + "not phone numbers or usernames. SimpleX supports standard " + "markdown formatting. There is no typing indicator and no " + "hard message length limit, but keep responses conversational." + ), + ) diff --git a/plugins/platforms/simplex/plugin.yaml b/plugins/platforms/simplex/plugin.yaml new file mode 100644 index 00000000000..2bb87641b63 --- /dev/null +++ b/plugins/platforms/simplex/plugin.yaml @@ -0,0 +1,37 @@ +name: simplex-platform +label: SimpleX Chat +kind: platform +version: 1.0.0 +description: > + SimpleX Chat gateway adapter for Hermes Agent. + Connects to a local simplex-chat daemon via WebSocket and relays + messages between SimpleX contacts/groups and the Hermes agent. + SimpleX is decentralised and assigns no persistent user IDs — + every contact is an opaque internal ID generated at connection + time, making it one of the most private messengers available. +author: Mibayy +# ``requires_env`` and ``optional_env`` entries are surfaced in the +# ``hermes config`` UI via the platform-plugin env var injector in +# ``hermes_cli/config.py``. +requires_env: + - name: SIMPLEX_WS_URL + description: "WebSocket URL of the simplex-chat daemon (e.g. ws://127.0.0.1:5225)" + prompt: "SimpleX daemon WebSocket URL" + password: false +optional_env: + - name: SIMPLEX_ALLOWED_USERS + description: "Comma-separated SimpleX contact IDs allowed to talk to the bot" + prompt: "Allowed contact IDs (comma-separated)" + password: false + - name: SIMPLEX_ALLOW_ALL_USERS + description: "Allow any contact to talk to the bot (dev only — disables allowlist)" + prompt: "Allow all contacts? (true/false)" + password: false + - name: SIMPLEX_HOME_CHANNEL + description: "Default contact/group ID for cron / notification delivery" + prompt: "Home channel contact/group ID (or empty)" + password: false + - name: SIMPLEX_HOME_CHANNEL_NAME + description: "Human label for the home channel (defaults to the ID)" + prompt: "Home channel display name (or empty)" + password: false diff --git a/tests/gateway/test_simplex_plugin.py b/tests/gateway/test_simplex_plugin.py new file mode 100644 index 00000000000..0b1b1b21a85 --- /dev/null +++ b/tests/gateway/test_simplex_plugin.py @@ -0,0 +1,347 @@ +"""Tests for the SimpleX Chat platform-plugin adapter. + +Loaded via the ``_plugin_adapter_loader`` helper so this lives under +``plugin_adapter_simplex`` in ``sys.modules`` and cannot collide with +sibling platform-plugin tests on the same xdist worker. +""" + +from __future__ import annotations + +import json +import os +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from tests.gateway._plugin_adapter_loader import load_plugin_adapter + +_simplex = load_plugin_adapter("simplex") + +SimplexAdapter = _simplex.SimplexAdapter +check_requirements = _simplex.check_requirements +validate_config = _simplex.validate_config +is_connected = _simplex.is_connected +register = _simplex.register +_env_enablement = _simplex._env_enablement +_standalone_send = _simplex._standalone_send +_guess_extension = _simplex._guess_extension +_is_image_ext = _simplex._is_image_ext +_is_audio_ext = _simplex._is_audio_ext +_CORR_PREFIX = _simplex._CORR_PREFIX + + +# --------------------------------------------------------------------------- +# 1. Platform enum (plugin-discovered, not bundled) +# --------------------------------------------------------------------------- + +def test_platform_enum_resolves_via_plugin_scan(): + """The plugin filesystem scan should expose Platform("simplex").""" + from gateway.config import Platform + p = Platform("simplex") + assert p.value == "simplex" + # Identity stability — repeated lookups return the same pseudo-member + assert Platform("simplex") is p + + +# --------------------------------------------------------------------------- +# 2. check_requirements / validate_config / is_connected +# --------------------------------------------------------------------------- + +def test_check_requirements_needs_url(monkeypatch): + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + assert check_requirements() is False + + +def test_check_requirements_true_when_configured(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + # websockets is a dev dep in this repo via the test plugins; the + # check_requirements() gate also asserts the package imports. + websockets_present = True + try: + import websockets # noqa: F401 + except ImportError: + websockets_present = False + assert check_requirements() is websockets_present + + +def test_validate_config_uses_env_or_extra(): + from gateway.config import PlatformConfig + # Empty extra + no env → invalid + cfg = PlatformConfig(enabled=True) + assert validate_config(cfg) is False + # extra-only path → valid + cfg2 = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + assert validate_config(cfg2) is True + + +def test_is_connected_mirrors_validate(monkeypatch): + from gateway.config import PlatformConfig + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://x"}) + assert is_connected(cfg) is True + assert is_connected(PlatformConfig(enabled=True)) is False + + +# --------------------------------------------------------------------------- +# 3. _env_enablement seeds PlatformConfig.extra +# --------------------------------------------------------------------------- + +def test_env_enablement_none_when_unset(monkeypatch): + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + assert _env_enablement() is None + + +def test_env_enablement_seeds_ws_url(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + monkeypatch.delenv("SIMPLEX_HOME_CHANNEL", raising=False) + seed = _env_enablement() + assert seed == {"ws_url": "ws://127.0.0.1:5225"} + + +def test_env_enablement_seeds_home_channel(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + monkeypatch.setenv("SIMPLEX_HOME_CHANNEL", "42") + monkeypatch.setenv("SIMPLEX_HOME_CHANNEL_NAME", "Personal") + seed = _env_enablement() + assert seed["home_channel"] == {"chat_id": "42", "name": "Personal"} + + +def test_env_enablement_home_channel_defaults_name_to_id(monkeypatch): + monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225") + monkeypatch.setenv("SIMPLEX_HOME_CHANNEL", "42") + monkeypatch.delenv("SIMPLEX_HOME_CHANNEL_NAME", raising=False) + seed = _env_enablement() + assert seed["home_channel"] == {"chat_id": "42", "name": "42"} + + +# --------------------------------------------------------------------------- +# 4. Adapter init +# --------------------------------------------------------------------------- + +def test_adapter_init_custom_url(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + assert adapter.ws_url == "ws://localhost:5225" + assert adapter._running is False + assert adapter._ws is None + + +def test_adapter_init_default_url(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True) + adapter = SimplexAdapter(cfg) + assert adapter.ws_url == "ws://127.0.0.1:5225" + + +def test_adapter_platform_identity(): + """Adapter should expose Platform("simplex") identity.""" + from gateway.config import Platform, PlatformConfig + cfg = PlatformConfig(enabled=True) + adapter = SimplexAdapter(cfg) + assert adapter.platform is Platform("simplex") + + +# --------------------------------------------------------------------------- +# 5. Helper functions (magic-byte detection) +# --------------------------------------------------------------------------- + +def test_guess_extension_png(): + assert _guess_extension(b"\x89PNG\r\n\x1a\n") == ".png" + + +def test_guess_extension_jpg(): + assert _guess_extension(b"\xff\xd8\xff\xe0") == ".jpg" + + +def test_guess_extension_ogg(): + assert _guess_extension(b"OggS\x00\x02") == ".ogg" + + +def test_guess_extension_unknown(): + assert _guess_extension(b"\x00\x01\x02\x03") == ".bin" + + +def test_is_image_ext(): + assert _is_image_ext(".png") is True + assert _is_image_ext(".webp") is True + assert _is_image_ext(".ogg") is False + + +def test_is_audio_ext(): + assert _is_audio_ext(".ogg") is True + assert _is_audio_ext(".mp3") is True + assert _is_audio_ext(".pdf") is False + + +# --------------------------------------------------------------------------- +# 6. Correlation IDs +# --------------------------------------------------------------------------- + +def test_corr_id_starts_with_prefix_and_tracks_pending(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + corr_id = adapter._make_corr_id() + assert corr_id.startswith(_CORR_PREFIX) + assert corr_id in adapter._pending_corr_ids + + +def test_corr_id_pending_set_self_trims(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + adapter._max_pending_corr = 4 + for _ in range(10): + adapter._make_corr_id() + # After many additions, the pending set should be bounded by the trim + # logic — at most one trim window above the cap. + assert len(adapter._pending_corr_ids) <= adapter._max_pending_corr + 1 + + +# --------------------------------------------------------------------------- +# 7. Outbound send (mocked WS) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_dm(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + + mock_ws = AsyncMock() + adapter._ws = mock_ws + + result = await adapter.send("contact-42", "Hello, SimpleX!") + mock_ws.send.assert_called_once() + payload = json.loads(mock_ws.send.call_args[0][0]) + assert payload["cmd"] == "@[contact-42] Hello, SimpleX!" + assert payload["corrId"].startswith(_CORR_PREFIX) + assert result.success is True + + +@pytest.mark.asyncio +async def test_send_group(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + + mock_ws = AsyncMock() + adapter._ws = mock_ws + + result = await adapter.send("group:grp-99", "Hello, group!") + payload = json.loads(mock_ws.send.call_args[0][0]) + assert payload["cmd"] == "#[grp-99] Hello, group!" + assert result.success is True + + +@pytest.mark.asyncio +async def test_send_when_ws_not_connected_does_not_crash(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + # No _ws assigned — _send_ws should drop quietly + result = await adapter.send("contact-42", "hi") + assert result.success is True # send() always returns success — fire-and-forget + + +# --------------------------------------------------------------------------- +# 8. Inbound: filter own-echo by corrId prefix +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_handle_event_filters_own_corr_id(): + from gateway.config import PlatformConfig + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + # Pretend we sent a command with this corrId + own = adapter._make_corr_id() + handler_mock = AsyncMock() + adapter._handle_new_chat_item = handler_mock # type: ignore + + await adapter._handle_event({"corrId": own, "type": "newChatItem"}) + handler_mock.assert_not_called() + assert own not in adapter._pending_corr_ids # discarded + + +# --------------------------------------------------------------------------- +# 9. Standalone (out-of-process) send for cron +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_standalone_send_missing_websockets(monkeypatch): + """When websockets is unimportable, return a clean error dict. + + Implementation detail: the standalone path does ``import websockets`` + inside the function body. We simulate the package being absent by + pulling it out of ``sys.modules`` and pointing the finder at None. + """ + import sys + saved_websockets = sys.modules.pop("websockets", None) + saved_meta = list(sys.meta_path) + + class _Blocker: + @staticmethod + def find_spec(name, path=None, target=None): + if name == "websockets" or name.startswith("websockets."): + raise ImportError("websockets blocked for test") + return None + + sys.meta_path.insert(0, _Blocker()) + try: + pconfig = MagicMock() + pconfig.extra = {"ws_url": "ws://localhost:5225"} + result = await _standalone_send(pconfig, "contact-42", "hi") + assert isinstance(result, dict) + assert "error" in result + assert "websockets" in result["error"] + finally: + sys.meta_path[:] = saved_meta + if saved_websockets is not None: + sys.modules["websockets"] = saved_websockets + + +@pytest.mark.asyncio +async def test_standalone_send_missing_url(monkeypatch): + monkeypatch.delenv("SIMPLEX_WS_URL", raising=False) + pconfig = MagicMock() + pconfig.extra = {} + # We expect the URL fallback (extra+env both empty) to be empty string, + # producing an error. We also need websockets to be importable for the + # url-check branch to be reached, so skip when it's not. + try: + import websockets.client # noqa: F401 + except ImportError: + pytest.skip("websockets not installed") + + result = await _standalone_send(pconfig, "contact-42", "hi") + assert isinstance(result, dict) + # Either error about URL or a connection attempt failure — both are valid + # signals that the standalone path requires configuration. + assert "error" in result + + +# --------------------------------------------------------------------------- +# 10. register() — plugin-side metadata +# --------------------------------------------------------------------------- + +def test_register_calls_register_platform(): + ctx = MagicMock() + register(ctx) + ctx.register_platform.assert_called_once() + kwargs = ctx.register_platform.call_args.kwargs + assert kwargs["name"] == "simplex" + assert kwargs["label"] == "SimpleX Chat" + assert kwargs["required_env"] == ["SIMPLEX_WS_URL"] + assert kwargs["allowed_users_env"] == "SIMPLEX_ALLOWED_USERS" + assert kwargs["allow_all_env"] == "SIMPLEX_ALLOW_ALL_USERS" + assert kwargs["cron_deliver_env_var"] == "SIMPLEX_HOME_CHANNEL" + assert callable(kwargs["check_fn"]) + assert callable(kwargs["validate_config"]) + assert callable(kwargs["is_connected"]) + assert callable(kwargs["env_enablement_fn"]) + assert callable(kwargs["standalone_sender_fn"]) + assert callable(kwargs["adapter_factory"]) + assert callable(kwargs["setup_fn"]) + # SimpleX uses opaque IDs only — no PII to redact. + assert kwargs["pii_safe"] is True diff --git a/website/docs/user-guide/messaging/simplex.md b/website/docs/user-guide/messaging/simplex.md new file mode 100644 index 00000000000..60853acd9f8 --- /dev/null +++ b/website/docs/user-guide/messaging/simplex.md @@ -0,0 +1,99 @@ +# SimpleX Chat + +[SimpleX Chat](https://simplex.chat/) is a private, decentralised messaging platform where users own their contacts and groups. Unlike other platforms, SimpleX assigns no persistent user IDs — every contact is identified by an opaque internal ID generated at connection time, which makes it one of the most private messengers available. + +## Prerequisites + +- The **simplex-chat** CLI installed and running as a daemon +- Python package **websockets** (`pip install websockets`) + +## Install simplex-chat + +Download the latest release from the [simplex-chat GitHub releases](https://github.com/simplex-chat/simplex-chat/releases) page, or via Docker: + +```bash +# Linux / macOS binary +curl -L https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64 -o simplex-chat +chmod +x simplex-chat + +# Or Docker +docker run -p 5225:5225 simplexchat/simplex-chat -p 5225 +``` + +## Start the daemon + +```bash +simplex-chat -p 5225 +``` + +The daemon listens on WebSocket at `ws://127.0.0.1:5225` by default. + +## Configure Hermes + +### Via setup wizard + +```bash +hermes setup gateway +``` + +Select **SimpleX Chat** and follow the prompts. + +### Via environment variables + +Add these to `~/.hermes/.env`: + +``` +SIMPLEX_WS_URL=ws://127.0.0.1:5225 +SIMPLEX_ALLOWED_USERS=, +SIMPLEX_HOME_CHANNEL= +``` + +| Variable | Required | Description | +|---|---|---| +| `SIMPLEX_WS_URL` | Yes | WebSocket URL of the simplex-chat daemon | +| `SIMPLEX_ALLOWED_USERS` | Recommended | Comma-separated contact IDs allowed to use the agent | +| `SIMPLEX_ALLOW_ALL_USERS` | Optional | Set `true` to allow every contact (use carefully) | +| `SIMPLEX_HOME_CHANNEL` | Optional | Default contact ID for cron job delivery | +| `SIMPLEX_HOME_CHANNEL_NAME` | Optional | Human label for the home channel | + +## Find your contact ID + +After starting the daemon, open a conversation with your agent contact. The contact ID will appear in session logs or via `hermes send_message action=list`. + +## Authorization + +By default **all contacts are denied**. You must either: + +1. Set `SIMPLEX_ALLOWED_USERS` to a comma-separated list of contact IDs, or +2. Use **DM pairing** — send any message to the bot and it will reply with a pairing code. Enter that code via `hermes gateway pair`. + +## Using SimpleX with cron jobs + +```python +cronjob( + action="create", + schedule="every 1h", + deliver="simplex", # uses SIMPLEX_HOME_CHANNEL + prompt="Check for alerts and summarise." +) +``` + +Or target a specific contact: + +```python +send_message(target="simplex:", message="Done!") +``` + +## Privacy notes + +- SimpleX never reveals phone numbers or email addresses — contacts use opaque IDs +- The connection between Hermes and the daemon is local WebSocket (`ws://127.0.0.1:5225`) — no data leaves your machine +- Messages are end-to-end encrypted by the SimpleX protocol before reaching the daemon + +## Troubleshooting + +**"Cannot reach daemon"** — Ensure `simplex-chat -p 5225` is running and the port matches `SIMPLEX_WS_URL`. + +**"websockets not installed"** — Run `pip install websockets`. + +**Messages not received** — Check that the contact's ID is in `SIMPLEX_ALLOWED_USERS` or approve them via DM pairing.