Supervisor gains normalized helpers (get_profile/address, update_profile, contacts, groups, history, send, create/leave/delete/join group, clear chat, delete contact) exposed as REST. Front end rebuilt to mirror simplex-manager: - sidebar layout; profiles list + per-profile detail page - Profile card + Edit dialog (display name / full name / bio) - Address card: SMP link + copy + QR (qrcode) + scan caption - Contacts (Chat / Clear / Delete), Groups (Create, Chat / Link / Leave / Delete, Join when invited), Create Channel (observer link) - in-GUI chat view: history + composer with live polling - live Event Log per profile over the /events WebSocket Validated via running server: address shown, profile edit persists, group create returns link. (json/JSONResponse imports fixed.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
5.9 KiB
Python
193 lines
5.9 KiB
Python
"""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)
|