Add chat actions, channels/groups mgmt, directory + deadmans bots, network status

Profiles/bots (profiles.py, main.py):
- Surface real send errors; fix group send and member-count staleness (refresh on view)
- Group/channel actions: join, leave, owner delete; consistent member counts
- Contacts: always-visible Chat, plus Clear chat and Delete contact
- Support bot: OpenAI-compatible LLM backend (Grok/Ollama/OpenAI) per-bot config
- Deadmans bot: check-in window, trigger message, recipients, owner
- Directory bot: add-to-group registration, super-user /approve /reject /list, search,
  publishes listing.json in the website schema (directory.py registry module)
- Profile edit (name/bio/avatar) + avatars on list pages
- Global status + /network page (operators, SMP/XFTP servers) and Settings network info

UI (templates):
- Chat rooms; collapsible left sidebar with notifications + network widget
- Per-directory-bot website generated on creation (name substituted)
- Matrix theme; copy/hyperlink addresses; site footer

Ignore runtime bot state and generated directory sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-03 21:26:16 +01:00
parent ecce417f6d
commit c1bb9cb955
13 changed files with 1446 additions and 29 deletions

View File

@@ -37,6 +37,10 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="SimpleX Manager", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
# Generated directory-bot websites (one folder per directory bot) — preview/host.
WEB_DIR = BASE.parent / "web"
WEB_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/directory", StaticFiles(directory=str(WEB_DIR), html=True), name="directory")
async def _save_address(profile_id: int, address: str) -> None:
@@ -122,7 +126,52 @@ async def bots_page(request: Request):
async def settings_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "settings.html", {"nav_active": "settings"})
return TEMPLATES.TemplateResponse(request, "settings.html", {
"nav_active": "settings",
"network": await pm.get_network_config(),
})
@app.get("/network", response_class=HTMLResponse)
async def network_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "network.html", {
"detail": await pm.get_servers_detail(),
"nav_active": "network",
})
@app.get("/notifications", response_class=HTMLResponse)
async def notifications_page(request: Request):
if redir := _redirect_if_unauth(request):
return redir
return TEMPLATES.TemplateResponse(request, "notifications.html", {
"items": pm.get_notifications(100),
"nav_active": "notifications",
})
@app.get("/api/status")
async def api_status(request: Request):
_require_auth(request)
status = await pm.global_status()
status["profiles_total"] = len(db.list_profiles())
status["online"] = status["profiles_running"] > 0
return JSONResponse(status)
@app.get("/api/notifications")
async def api_notifications(request: Request):
_require_auth(request)
return JSONResponse({"unread": pm.unread_count(), "items": pm.get_notifications(50)})
@app.post("/api/notifications/read")
async def api_notifications_read(request: Request):
_require_auth(request)
pm.mark_all_read()
return JSONResponse({"ok": True})
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
@@ -134,6 +183,8 @@ async def profile_page(request: Request, profile_id: int):
raise HTTPException(404, "Profile not found")
profile["config"] = json.loads(profile.get("config") or "{}")
profile["running"] = pm.is_running(profile_id)
# Refresh cached lists so member counts / contacts are current on view
await pm.refresh_lists(profile_id)
bot = pm.get_running(profile_id)
contacts = bot.contacts if bot else []
groups = bot.groups if bot else []
@@ -217,7 +268,31 @@ async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id:
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)
try:
await pm.send_to_chat(profile_id, chat_type, chat_id, text)
except Exception as e:
log.error("chat send failed (profile=%s %s/%s): %s", profile_id, chat_type, chat_id, e)
return JSONResponse({"ok": False, "error": str(e)})
return JSONResponse({"ok": True})
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/clear")
async def chat_clear(request: Request, profile_id: int, chat_type: str, chat_id: int):
_require_auth(request)
try:
ok = await pm.clear_chat(profile_id, chat_type, chat_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@app.delete("/api/profiles/{profile_id}/contacts/{contact_id}")
async def contact_delete(request: Request, profile_id: int, contact_id: int):
_require_auth(request)
try:
ok = await pm.delete_contact(profile_id, contact_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@@ -238,9 +313,32 @@ async def create_profile(request: Request):
profile = db.create_profile(name, bot_type, config)
except Exception as e:
raise HTTPException(400, str(e))
# Directory bots get their own auto-generated listing website
if bot_type == "directory":
try:
profile["site"] = pm.generate_directory_site(name)
except Exception as e:
log.error("directory site generation failed: %s", e)
return JSONResponse(profile, status_code=201)
@app.post("/api/profiles/{profile_id}/profile")
async def edit_profile(request: Request, profile_id: int):
_require_auth(request)
if not db.get_profile(profile_id):
raise HTTPException(404, "Profile not found")
data = await request.json()
# Keys absent from the body are left unchanged; avatar="" removes the avatar.
full_name = data.get("full_name")
bio = data.get("bio")
avatar = data.get("avatar") # None = unchanged, "" = remove, str = replace
try:
config = await pm.update_profile(profile_id, full_name, bio, avatar)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": True, "config": config})
@app.delete("/api/profiles/{profile_id}")
async def delete_profile(request: Request, profile_id: int):
_require_auth(request)
@@ -318,6 +416,36 @@ async def create_group(request: Request, profile_id: int):
return JSONResponse({"ok": True, "link": link})
@app.post("/api/profiles/{profile_id}/groups/{group_id}/join")
async def group_join(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
ok = await pm.join_group(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@app.post("/api/profiles/{profile_id}/groups/{group_id}/leave")
async def group_leave(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
ok = await pm.leave_group(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@app.delete("/api/profiles/{profile_id}/groups/{group_id}")
async def group_delete(request: Request, profile_id: int, group_id: int):
_require_auth(request)
try:
ok = await pm.delete_group(profile_id, group_id)
except Exception as e:
raise HTTPException(400, str(e))
return JSONResponse({"ok": ok})
@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)