Files
simplex-manager/manager/main.py
Jon ecce417f6d Add chat rooms, channels, sidebar nav, themes, and UI polish
Backend (profiles.py / main.py):
- Fix bot startup crash: create active user before start_chat
- Fix address-clobbering bug on restart (UserContactLink vs CreatedConnLink)
- Add user profile type alongside bots
- Channels: create groups with observer links, classify via acceptMemberRole
- Chat rooms: get_chat_history + send_to_chat, history/messages/send routes

UI:
- Chat room view with message bubbles and live polling
- Convert top nav to collapsible left sidebar (mobile-friendly off-canvas)
- Three-way Users/Bots split; clickable cards; copy-address buttons + links
- Add Matrix theme alongside Original Light/Dark
- File upload sidebar link; site footer on all pages incl. login
- Bot-type descriptions on the Bots page; QR caption on profiles

Remove orphaned index.html (superseded by list.html).
Ignore downloaded libs/, exploration DBs, and local ai.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:48:24 +01:00

344 lines
12 KiB
Python

"""SimpleX Manager — FastAPI app."""
import asyncio
import json
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import db
import profiles as pm
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger(__name__)
BASE = Path(__file__).parent
TEMPLATES = Jinja2Templates(directory=str(BASE / "templates"))
AUTH_TOKEN = os.environ.get("MANAGER_TOKEN", "changeme")
@asynccontextmanager
async def lifespan(app: FastAPI):
db.init_db()
for profile in db.list_profiles():
if profile.get("address"):
await pm.start_bot(profile, _save_address)
yield
for pid in list(pm._running):
await pm.stop_bot(pid)
app = FastAPI(title="SimpleX Manager", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
async def _save_address(profile_id: int, address: str) -> None:
db.update_address(profile_id, address)
def _check_auth(request: Request) -> bool:
token = request.cookies.get("token") or request.headers.get("X-Token")
return token == AUTH_TOKEN
def _require_auth(request: Request) -> None:
if not _check_auth(request):
raise HTTPException(status_code=401, detail="Unauthorized")
def _redirect_if_unauth(request: Request):
if not _check_auth(request):
return RedirectResponse("/login", status_code=303)
return None
def _enrich(profiles: list[dict]) -> list[dict]:
for p in profiles:
p["running"] = pm.is_running(p["id"])
p["config"] = json.loads(p.get("config") or "{}")
return profiles
# ── Auth ──────────────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return TEMPLATES.TemplateResponse(request, "login.html")
@app.post("/login")
async def login(request: Request, token: str = Form(...)):
if token != AUTH_TOKEN:
return TEMPLATES.TemplateResponse(request, "login.html", {"error": "Invalid token"})
resp = RedirectResponse("/", status_code=303)
resp.set_cookie("token", token, httponly=True, samesite="lax")
return resp
@app.get("/logout")
async def logout():
resp = RedirectResponse("/login", status_code=303)
resp.delete_cookie("token")
return resp
# ── Pages ─────────────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return RedirectResponse("/users", status_code=302)
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.USER_TYPES])
return TEMPLATES.TemplateResponse(request, "list.html", {
"tab": "users", "items": items, "create_types": pm.USER_TYPES,
"nav_active": "users",
})
@app.get("/bots", response_class=HTMLResponse)
async def bots_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.BOT_TYPES])
return TEMPLATES.TemplateResponse(request, "list.html", {
"tab": "bots", "items": items, "create_types": pm.BOT_TYPES,
"nav_active": "bots",
})
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "settings.html", {"nav_active": "settings"})
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
async def profile_page(request: Request, profile_id: int):
if redir := _redirect_if_unauth(request):
return redir
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(404, "Profile not found")
profile["config"] = json.loads(profile.get("config") or "{}")
profile["running"] = pm.is_running(profile_id)
bot = pm.get_running(profile_id)
contacts = bot.contacts if bot else []
groups = bot.groups if bot else []
log_lines = bot.log_lines[-50:] if bot else []
is_user = profile["bot_type"] in pm.USER_TYPES
# Split groups by their link role: channels (observer) vs regular groups (member).
# The is_channel flag is set during the bot's group refresh (see _classify_group).
channels = [g for g in groups if g.get("is_channel")]
plain_groups = [g for g in groups if not g.get("is_channel")]
return TEMPLATES.TemplateResponse(request, "profile.html", {
"profile": profile,
"contacts": contacts,
"groups": plain_groups,
"channels": channels,
"log_lines": log_lines,
"back": "/users" if is_user else "/bots",
"nav_active": "users" if is_user else "bots",
})
def _find_chat_name(bot, chat_type: str, chat_id: int) -> str:
"""Resolve a chat's display name from the running bot's cached lists."""
if not bot:
return ""
if chat_type == "direct":
for c in bot.contacts:
if c["contactId"] == chat_id:
return c["localDisplayName"]
elif chat_type == "group":
for g in bot.groups:
if pm.group_id(g) == chat_id:
return pm.group_name(g)
return ""
@app.get("/profile/{profile_id}/chat/{chat_type}/{chat_id}", response_class=HTMLResponse)
async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id: int):
if redir := _redirect_if_unauth(request):
return redir
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(404, "Profile not found")
if chat_type not in ("direct", "group"):
raise HTTPException(400, "chat_type must be 'direct' or 'group'")
bot = pm.get_running(profile_id)
name = _find_chat_name(bot, chat_type, chat_id) or f"#{chat_id}"
# Is this group a channel? (affects send affordance: broadcast vs reply)
is_channel = False
if chat_type == "group" and bot:
for g in bot.groups:
if pm.group_id(g) == chat_id:
is_channel = bool(g.get("is_channel"))
break
is_user = profile["bot_type"] in pm.USER_TYPES
return TEMPLATES.TemplateResponse(request, "chat.html", {
"profile": profile,
"running": pm.is_running(profile_id),
"chat_type": chat_type,
"chat_id": chat_id,
"chat_name": name,
"is_channel": is_channel,
"nav_active": "users" if is_user else "bots",
})
@app.get("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/messages")
async def chat_messages(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
count = int(request.query_params.get("count", 50))
try:
messages = await pm.get_chat_history(profile_id, chat_type, chat_id, count)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"messages": messages})
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/send")
async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
data = await request.json()
text = data.get("text", "").strip()
if not text:
raise HTTPException(400, "text required")
ok = await pm.send_to_chat(profile_id, chat_type, chat_id, text)
return JSONResponse({"ok": ok})
# ── Profile API ───────────────────────────────────────────────────────────────
@app.post("/api/profiles")
async def create_profile(request: Request):
_require_auth(request)
data = await request.json()
name = data.get("name", "").strip()
bot_type = data.get("bot_type", "echo")
config = data.get("config", {})
if not name:
raise HTTPException(400, "name required")
if bot_type not in pm.ALL_TYPES:
raise HTTPException(400, f"bot_type must be one of {pm.ALL_TYPES}")
try:
profile = db.create_profile(name, bot_type, config)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse(profile, status_code=201)
@app.delete("/api/profiles/{profile_id}")
async def delete_profile(request: Request, profile_id: int):
_require_auth(request)
await pm.stop_bot(profile_id)
db.delete_profile(profile_id)
return JSONResponse({"ok": True})
@app.post("/api/profiles/{profile_id}/start")
async def start_profile(request: Request, profile_id: int):
_require_auth(request)
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(404, "Profile not found")
await pm.start_bot(profile, _save_address)
return JSONResponse({"ok": True, "status": "starting"})
@app.post("/api/profiles/{profile_id}/stop")
async def stop_profile(request: Request, profile_id: int):
_require_auth(request)
await pm.stop_bot(profile_id)
return JSONResponse({"ok": True, "status": "stopped"})
@app.get("/api/profiles/{profile_id}/status")
async def profile_status(request: Request, profile_id: int):
_require_auth(request)
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(404)
bot = pm.get_running(profile_id)
return JSONResponse({
"running": bot is not None,
"address": bot.address if bot else profile.get("address", ""),
"contacts": len(bot.contacts) if bot else 0,
"groups": len(bot.groups) if bot else 0,
"log": bot.log_lines[-20:] if bot else [],
})
@app.post("/api/profiles/{profile_id}/send")
async def send_message(request: Request, profile_id: int):
_require_auth(request)
data = await request.json()
to = data.get("to", "")
text = data.get("text", "")
if not to or not text:
raise HTTPException(400, "to and text required")
ok = await pm.send_message(profile_id, to, text)
return JSONResponse({"ok": ok})
# ── Group / Channel API ───────────────────────────────────────────────────────
@app.post("/api/profiles/{profile_id}/groups")
async def create_group(request: Request, profile_id: int):
"""Create a group (kind='group', 2-way) or channel (kind='channel', broadcast)."""
_require_auth(request)
profile = db.get_profile(profile_id)
if not profile:
raise HTTPException(404, "Profile not found")
data = await request.json()
name = data.get("name", "").strip()
kind = data.get("kind", "group")
if not name:
raise HTTPException(400, "name required")
try:
if kind == "channel":
link = await pm.create_channel(profile_id, name)
else:
link = await pm.create_group(profile_id, name)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": True, "link": link})
@app.get("/api/profiles/{profile_id}/groups/{group_id}/members")
async def group_members(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
members = await pm.get_group_members(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"members": members})
@app.get("/api/profiles/{profile_id}/groups/{group_id}/link")
async def group_link(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
link = await pm.get_group_link(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"link": link})
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)