"""Front-facing API the website talks to. The browser: - opens a WebSocket to /events to receive live chat events from every profile (each event is tagged with the originating profile name); - uses REST to spawn/stop profiles and to send commands to cli profiles. This process sits between the website and the SimpleX binaries; it is the only thing that touches the binaries. Run: uvicorn supervisor.server:app """ import contextlib from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, JSONResponse from .supervisor import Supervisor app = FastAPI(title="SimpleX Orchestrate") WEBUI = Path(__file__).resolve().parent.parent / "webui" / "index.html" @app.get("/") async def index() -> FileResponse: return FileResponse(WEBUI) _browser_clients: set[WebSocket] = set() async def _broadcast(name: str, event: dict) -> None: dead = [] for ws in _browser_clients: try: await ws.send_json({"profile": name, "event": event}) except Exception: dead.append(ws) for ws in dead: _browser_clients.discard(ws) sup = Supervisor(on_event=_broadcast) @app.on_event("shutdown") async def _shutdown() -> None: await sup.stop_all() # ── Control (REST) ────────────────────────────────────────────────────────────── @app.get("/profiles") async def profiles() -> dict: return {"profiles": sup.list()} @app.post("/profiles/{name}/start-cli") async def start_cli(name: str) -> dict: await sup.start_cli(name) return {"ok": True} @app.post("/profiles/{name}/start-directory") async def start_directory(name: str, body: dict) -> dict: await sup.start_directory(name, body["super_users"], body["web_folder"]) return {"ok": True} @app.post("/profiles/{name}/start-broadcast") async def start_broadcast(name: str, body: dict) -> dict: await sup.start_broadcast(name, body["display_name"], body["publishers"]) return {"ok": True} @app.post("/profiles/{name}/cmd") async def cmd(name: str, body: dict) -> dict: """Send a raw chat command string to a cli profile (e.g. '/_send @1 text hi').""" return {"resp": await sup.send(name, body["cmd"])} # ── High-level profile operations (normalized for the GUI) ─────────────────────── async def _json(coro): """Await a helper coroutine; return its result as JSON, or {error} on failure.""" try: return JSONResponse(await coro) except Exception as e: return JSONResponse({"error": str(e)}, status_code=400) @app.get("/profiles/{name}/profile") async def get_profile(name: str): return await _json(sup.get_profile(name)) @app.post("/profiles/{name}/profile") async def set_profile(name: str, body: dict): async def do(): await sup.update_profile(name, body.get("display_name", name), body.get("full_name", ""), body.get("bio", "")) return {"ok": True} return await _json(do()) @app.get("/profiles/{name}/contacts") async def contacts(name: str): return await _json(sup.get_contacts(name)) @app.get("/profiles/{name}/groups") async def groups(name: str): return await _json(sup.get_groups(name)) @app.post("/profiles/{name}/groups") async def create_group(name: str, body: dict): return await _json(sup.create_group(name, body["name"], bool(body.get("observer")))) @app.get("/profiles/{name}/groups/{gid}/link") async def group_link(name: str, gid: int): async def do(): return {"link": await sup.group_link(name, gid)} return await _json(do()) @app.post("/profiles/{name}/groups/{gid}/leave") async def leave_group(name: str, gid: int): async def do(): await sup.leave_group(name, gid); return {"ok": True} return await _json(do()) @app.post("/profiles/{name}/groups/{gid}/join") async def join_group(name: str, gid: int): async def do(): await sup.join_group(name, gid); return {"ok": True} return await _json(do()) @app.delete("/profiles/{name}/groups/{gid}") async def delete_group(name: str, gid: int): async def do(): await sup.delete_group(name, gid); return {"ok": True} return await _json(do()) @app.get("/profiles/{name}/chat/{chat_type}/{chat_id}/history") async def history(name: str, chat_type: str, chat_id: int, count: int = 50): async def do(): return {"messages": await sup.get_history(name, chat_type, chat_id, count)} return await _json(do()) @app.post("/profiles/{name}/chat/{chat_type}/{chat_id}/send") async def chat_send(name: str, chat_type: str, chat_id: int, body: dict): async def do(): await sup.send_message(name, chat_type, chat_id, body["text"]); return {"ok": True} return await _json(do()) @app.post("/profiles/{name}/chat/{chat_type}/{chat_id}/clear") async def chat_clear(name: str, chat_type: str, chat_id: int): async def do(): await sup.clear_chat(name, chat_type, chat_id); return {"ok": True} return await _json(do()) @app.delete("/profiles/{name}/contacts/{contact_id}") async def contact_delete(name: str, contact_id: int): async def do(): await sup.delete_contact(name, contact_id); return {"ok": True} return await _json(do()) @app.post("/profiles/{name}/stop") async def stop(name: str) -> dict: await sup.stop(name) return {"ok": True} # ── Live events (WebSocket) ────────────────────────────────────────────────────── @app.websocket("/events") async def events(ws: WebSocket) -> None: await ws.accept() _browser_clients.add(ws) try: while True: await ws.receive_text() # keep the socket open; ignore inbound except WebSocketDisconnect: pass finally: _browser_clients.discard(ws)