Add Python manager: FastAPI backend + web UI

- main.py: FastAPI app with profile CRUD, start/stop, send message endpoints
- profiles.py: asyncio bot lifecycle using simplex-chat Python SDK
- db.py: SQLite registry tracking profiles, types, config, addresses
- templates/: Jinja2 + HTMX web UI
  - login.html: token-based auth
  - index.html: profile list with live status polling, create dialog
  - profile.html: per-bot dashboard with QR code, contacts/groups, event log, send form
- requirements.txt: fastapi, uvicorn, jinja2, simplex-chat
- start.sh: one-command startup with venv bootstrap

Bot types: echo, broadcast, support (business address), directory, deadmans
Run: cd manager && MANAGER_TOKEN=secret ./start.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-03 00:53:41 +01:00
parent 5c80ac310f
commit 11e799188d
10 changed files with 1008 additions and 0 deletions

194
manager/main.py Normal file
View File

@@ -0,0 +1,194 @@
"""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()
# Auto-restart any previously running bots on startup
for profile in db.list_profiles():
if profile.get("address"): # had an address = was running before
await pm.start_bot(profile, _save_address)
yield
# Graceful shutdown
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")
# ── Auth ──────────────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return TEMPLATES.TemplateResponse("login.html", {"request": request})
@app.post("/login")
async def login(request: Request, token: str = Form(...)):
if token != AUTH_TOKEN:
return TEMPLATES.TemplateResponse("login.html", {"request": request, "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):
_require_auth(request)
all_profiles = db.list_profiles()
for p in all_profiles:
p["running"] = pm.is_running(p["id"])
p["config"] = json.loads(p.get("config") or "{}")
return TEMPLATES.TemplateResponse("index.html", {
"request": request,
"profiles": all_profiles,
"bot_types": pm.BOT_TYPES,
})
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
async def profile_page(request: Request, profile_id: int):
_require_auth(request)
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 []
return TEMPLATES.TemplateResponse("profile.html", {
"request": request,
"profile": profile,
"contacts": contacts,
"groups": groups,
"log_lines": log_lines,
"bot_types": pm.BOT_TYPES,
})
# ── 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.BOT_TYPES:
raise HTTPException(400, f"bot_type must be one of {pm.BOT_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})
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)