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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user