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