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:
194
manager/main.py
Normal file
194
manager/main.py
Normal 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)
|
||||
Reference in New Issue
Block a user