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>
344 lines
12 KiB
Python
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)
|