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>
This commit is contained in:
Jon
2026-06-03 14:48:24 +01:00
parent 2d9cb4581a
commit ecce417f6d
12 changed files with 1371 additions and 272 deletions

View File

@@ -27,12 +27,10 @@ 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
if profile.get("address"):
await pm.start_bot(profile, _save_address)
yield
# Graceful shutdown
for pid in list(pm._running):
await pm.stop_bot(pid)
@@ -52,7 +50,6 @@ def _check_auth(request: Request) -> bool:
def _require_auth(request: Request) -> None:
if not _check_auth(request):
from fastapi.responses import RedirectResponse
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -62,6 +59,13 @@ def _redirect_if_unauth(request: Request):
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)
@@ -89,18 +93,38 @@ async def logout():
@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
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(request, "index.html", {
"profiles": all_profiles,
"bot_types": pm.BOT_TYPES,
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):
@@ -114,16 +138,90 @@ async def profile_page(request: Request, profile_id: int):
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": groups,
"groups": plain_groups,
"channels": channels,
"log_lines": log_lines,
"bot_types": pm.BOT_TYPES,
"back": "/users" if is_user else "/bots",
"nav_active": "users" if is_user else "bots",
})
# ── API ───────────────────────────────────────────────────────────────────────
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):
@@ -134,8 +232,8 @@ async def create_profile(request: Request):
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}")
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:
@@ -196,6 +294,50 @@ async def send_message(request: Request, profile_id: int):
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)