Files
simplex-orchestrate/supervisor/ws_client.py
Jon 38ff96c576 Scaffold SimpleX Orchestrate: supervisor over official binaries
A standalone control-plane app that spawns and drives the official SimpleX
binaries (never modifies simplex source). Validated against simplex-chat
built from source (stable v6.5.4, GHC 9.6.3).

- CLAUDE.md: architecture notes mined from the upstream docs (WebSocket bot API,
  per-profile DBs, directory/broadcast bot config)
- supervisor/: process registry + port allocation (supervisor.py), corrId/cmd<->resp
  WebSocket client (ws_client.py), binary locator (binaries.py), FastAPI front with
  REST control + /events stream (server.py)
- smoke_test.py: Pattern-1 handshake (spawn simplex-chat -p, create+read user) — PASS
- group_test.py: two accounts, invitation connect + group invite/join, verified
  membership over the real SMP network — PASS
- build_chat.sh / install_ghc.sh: reproducible toolchain + from-source build

Key finding: fresh DB prompts for a display name on stdin; spawn with
--create-bot-display-name to start the WebSocket server non-interactively.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:31:37 +01:00

74 lines
2.7 KiB
Python

"""WebSocket client for a `simplex-chat -p <port>` process (Pattern 1 control).
Protocol (simplex-chat/bots/README.md):
- send {"corrId": "<unique>", "cmd": "<command string>"}
- response{"corrId": "<same>", "resp": {"type": ..., ...}} (matched by corrId)
- event {"resp": {"type": ..., ...}} (no corrId)
We keep one long-lived connection per profile. Responses resolve futures keyed by
corrId; events (no corrId) are pushed to an async callback.
"""
import asyncio
import itertools
import json
from collections.abc import Awaitable, Callable
import websockets
EventHandler = Callable[[dict], Awaitable[None]]
class SimplexWSClient:
def __init__(self, port: int, on_event: EventHandler | None = None):
self.url = f"ws://localhost:{port}/"
self._on_event = on_event
self._ws: websockets.WebSocketClientProtocol | None = None
self._corr = itertools.count(1)
self._pending: dict[str, asyncio.Future] = {}
self._reader: asyncio.Task | None = None
async def connect(self) -> None:
# max_size=None: chat responses (chat lists, profiles w/ avatars) can be large.
self._ws = await websockets.connect(self.url, max_size=None)
self._reader = asyncio.create_task(self._read_loop())
async def _read_loop(self) -> None:
assert self._ws is not None
async for raw in self._ws:
try:
msg = json.loads(raw)
except Exception:
continue
corr = msg.get("corrId")
resp = msg.get("resp")
fut = self._pending.pop(corr, None) if corr else None
if fut is not None:
if not fut.done():
fut.set_result(resp)
elif resp is not None and self._on_event is not None:
# Forward-compat: never fail on unknown event types (bots/README.md)
try:
await self._on_event(resp)
except Exception:
pass
async def send_cmd(self, cmd: str, timeout: float = 30.0) -> dict:
"""Send a chat command string, await its matching response record."""
if self._ws is None:
raise RuntimeError("not connected")
corr = str(next(self._corr))
fut: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[corr] = fut
await self._ws.send(json.dumps({"corrId": corr, "cmd": cmd}))
try:
return await asyncio.wait_for(fut, timeout)
finally:
self._pending.pop(corr, None)
async def close(self) -> None:
if self._reader:
self._reader.cancel()
if self._ws:
await self._ws.close()