From d098b1d6cecf16f5db06da0c3c4726aee084c070 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 4 Jun 2026 13:07:48 +0100 Subject: [PATCH] Rich management GUI: profile detail, address+QR, chat, edit, groups 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 --- supervisor/server.py | 96 ++++++- supervisor/supervisor.py | 137 ++++++++++ webui/index.html | 538 ++++++++++++++++++++------------------- 3 files changed, 512 insertions(+), 259 deletions(-) diff --git a/supervisor/server.py b/supervisor/server.py index 207d3c5..ba854dc 100644 --- a/supervisor/server.py +++ b/supervisor/server.py @@ -13,7 +13,7 @@ import contextlib from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from .supervisor import Supervisor @@ -78,6 +78,100 @@ async def cmd(name: str, body: dict) -> dict: 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) diff --git a/supervisor/supervisor.py b/supervisor/supervisor.py index bfd1aca..79b4ea0 100644 --- a/supervisor/supervisor.py +++ b/supervisor/supervisor.py @@ -12,8 +12,11 @@ NOTE: exact flag spellings for the autonomous bots should be confirmed with apps/simplex-directory-service/src/Directory/Options.hs. """ +from __future__ import annotations + import asyncio import contextlib +import json from collections.abc import Awaitable, Callable from dataclasses import dataclass from pathlib import Path @@ -32,6 +35,7 @@ class Managed: proc: asyncio.subprocess.Process port: int | None = None client: SimplexWSClient | None = None + uid: int | None = None # cached active-user id for cli profiles class Supervisor: @@ -93,6 +97,139 @@ class Supervisor: raise RuntimeError(f"{name!r} is not a running cli profile") return await m.client.send_cmd(cmd) + # ── High-level helpers (normalize the binary's responses for the GUI) ─────── + def _cli(self, name: str) -> "Managed": + m = self._procs.get(name) + if not m or not m.client: + raise RuntimeError(f"{name!r} is not a running cli profile") + return m + + async def _uid(self, name: str) -> int: + m = self._cli(name) + if m.uid is None: + r = await m.client.send_cmd("/user") + m.uid = r["user"]["userId"] + return m.uid + + async def get_profile(self, name: str) -> dict: + m = self._cli(name) + u = (await m.client.send_cmd("/user")).get("user", {}) + p = u.get("profile", {}) + return { + "displayName": p.get("displayName", name), + "fullName": p.get("fullName", ""), + "bio": p.get("shortDescr", ""), + "image": p.get("image", ""), + "address": await self.get_address(name), + } + + async def get_address(self, name: str) -> str: + m = self._cli(name) + uid = await self._uid(name) + r = await m.client.send_cmd(f"/_show_address {uid}") + if r.get("type") == "userContactLink": + link = r["contactLink"]["connLinkContact"] + else: + rc = await m.client.send_cmd(f"/_address {uid}") + link = rc.get("connLinkContact", {}) if rc.get("type") == "userContactLinkCreated" else {} + # auto-accept incoming contact requests so the address is usable + settings = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}} + await m.client.send_cmd(f"/_address_settings {uid} " + json.dumps(settings)) + return link.get("connShortLink") or link.get("connFullLink", "") + + async def update_profile(self, name: str, display_name: str, full_name: str, bio: str) -> bool: + m = self._cli(name) + uid = await self._uid(name) + profile = {"displayName": display_name, "fullName": full_name} + if bio: + profile["shortDescr"] = bio + await m.client.send_cmd(f"/_profile {uid} " + json.dumps(profile)) + return True + + async def get_contacts(self, name: str) -> list[dict]: + m = self._cli(name) + uid = await self._uid(name) + r = await m.client.send_cmd(f"/_contacts {uid}") + return [ + {"contactId": c["contactId"], "name": c["localDisplayName"]} + for c in r.get("contacts", []) + ] + + async def get_groups(self, name: str) -> list[dict]: + m = self._cli(name) + uid = await self._uid(name) + r = await m.client.send_cmd(f"/_groups {uid}") + out = [] + for g in r.get("groups", []): + mem = g.get("membership", {}) + out.append({ + "groupId": g["groupId"], + "name": g["groupProfile"]["displayName"], + "members": g.get("groupSummary", {}).get("currentMembers", 0), + "role": mem.get("memberRole", ""), + "status": mem.get("memberStatus", ""), + }) + return out + + async def get_history(self, name: str, chat_type: str, chat_id: int, count: int = 50) -> list[dict]: + m = self._cli(name) + ref = ("@" if chat_type == "direct" else "#") + str(chat_id) + r = await m.client.send_cmd(f"/_get chat {ref} count={count}") + items = r.get("chat", {}).get("chatItems", []) if r.get("type") == "apiChat" else [] + out = [] + for ci in items: + meta = ci.get("meta", {}) + d = ci.get("chatDir", {}).get("type", "") + text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "") + sender = ci.get("chatDir", {}).get("groupMember", {}).get("localDisplayName", "") if d == "groupRcv" else "" + out.append({"id": meta.get("itemId"), "ts": meta.get("itemTs", ""), + "text": text, "outgoing": d.endswith("Snd"), "sender": sender}) + return out + + async def send_message(self, name: str, chat_type: str, chat_id: int, text: str) -> bool: + m = self._cli(name) + ref = ("@" if chat_type == "direct" else "#") + str(chat_id) + msgs = [{"msgContent": {"type": "text", "text": text}, "mentions": {}}] + await m.client.send_cmd(f"/_send {ref} json " + json.dumps(msgs)) + return True + + async def create_group(self, name: str, group_name: str, observer: bool = False) -> dict: + m = self._cli(name) + uid = await self._uid(name) + gi = await m.client.send_cmd(f"/_group {uid} " + json.dumps({"displayName": group_name, "fullName": ""})) + gid = gi["groupInfo"]["groupId"] + role = "observer" if observer else "member" + lk = await m.client.send_cmd(f"/_create link #{gid} {role}") + link = lk.get("groupLink", {}).get("connLinkContact", {}) if lk.get("type") == "groupLinkCreated" else {} + return {"groupId": gid, "link": link.get("connShortLink") or link.get("connFullLink", "")} + + async def group_link(self, name: str, gid: int) -> str: + m = self._cli(name) + r = await m.client.send_cmd(f"/_get link #{gid}") + link = r.get("groupLink", {}).get("connLinkContact", {}) if r.get("type") == "groupLink" else {} + return link.get("connShortLink") or link.get("connFullLink", "") + + async def leave_group(self, name: str, gid: int) -> bool: + await self._cli(name).client.send_cmd(f"/_leave #{gid}") + return True + + async def delete_group(self, name: str, gid: int) -> bool: + await self._cli(name).client.send_cmd(f"/_delete #{gid} full") + return True + + async def join_group(self, name: str, gid: int) -> bool: + await self._cli(name).client.send_cmd(f"/_join #{gid}") + return True + + async def clear_chat(self, name: str, chat_type: str, chat_id: int) -> bool: + ref = ("@" if chat_type == "direct" else "#") + str(chat_id) + await self._cli(name).client.send_cmd(f"/_delete {ref} messages") + return True + + async def delete_contact(self, name: str, contact_id: int) -> bool: + await self._cli(name).client.send_cmd(f"/_delete @{contact_id} full") + return True + # ── Pattern 2: autonomous official bots (lifecycle + config-at-launch) ────── async def start_directory(self, name: str, super_users: str, web_folder: str, extra_args: tuple[str, ...] = ()) -> Managed: diff --git a/webui/index.html b/webui/index.html index c9fa29f..d24b597 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4,296 +4,318 @@ SimpleX Orchestrate + -
-
- ◆ SimpleX Orchestrate - connecting… -
-
- -
-

Manager

-
- - - -
- - -
-
-

New profile

-
- - -
-
- - - - -
- -
-
- - -
-
-

Send command (cli profiles)

-
- - - -
-

Raw chat commands — same as the app's Chat Console (e.g. /user, /_contacts 1, /_groups 1).

- -
-
- - -
-
-
-

Live events

- -
-
+
+ +
+
+
© Bournemouth Technology Ltd · built on © SimpleX Network · + Get SimpleX App
+ + +

Edit profile

+
+
+
+
+ +
+
+
+
+ + + +

New group

+
+ +
+ +
+
+
+
+