From 11e799188d31ddd7d5ce634f17aaa3fa84dacab8 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 3 Jun 2026 00:53:41 +0100 Subject: [PATCH] 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 --- manager/data/.gitkeep | 0 manager/db.py | 66 +++++++++ manager/main.py | 194 ++++++++++++++++++++++++++ manager/profiles.py | 243 +++++++++++++++++++++++++++++++++ manager/requirements.txt | 5 + manager/start.sh | 19 +++ manager/templates/base.html | 139 +++++++++++++++++++ manager/templates/index.html | 112 +++++++++++++++ manager/templates/login.html | 40 ++++++ manager/templates/profile.html | 190 ++++++++++++++++++++++++++ 10 files changed, 1008 insertions(+) create mode 100644 manager/data/.gitkeep create mode 100644 manager/db.py create mode 100644 manager/main.py create mode 100644 manager/profiles.py create mode 100644 manager/requirements.txt create mode 100755 manager/start.sh create mode 100644 manager/templates/base.html create mode 100644 manager/templates/index.html create mode 100644 manager/templates/login.html create mode 100644 manager/templates/profile.html diff --git a/manager/data/.gitkeep b/manager/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/manager/db.py b/manager/db.py new file mode 100644 index 0000000..05267bd --- /dev/null +++ b/manager/db.py @@ -0,0 +1,66 @@ +"""Profile registry — SQLite database for manager state.""" + +import sqlite3 +import json +from pathlib import Path +from typing import Any + +DB_PATH = Path(__file__).parent / "data" / "manager.db" + + +def get_conn() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db() -> None: + with get_conn() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + bot_type TEXT NOT NULL, + db_prefix TEXT NOT NULL UNIQUE, + config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + address TEXT + ); + """) + + +def list_profiles() -> list[dict]: + with get_conn() as conn: + rows = conn.execute("SELECT * FROM profiles ORDER BY id").fetchall() + return [dict(r) for r in rows] + + +def get_profile(profile_id: int) -> dict | None: + with get_conn() as conn: + row = conn.execute("SELECT * FROM profiles WHERE id=?", (profile_id,)).fetchone() + return dict(row) if row else None + + +def create_profile(name: str, bot_type: str, config: dict) -> dict: + safe = name.lower().replace(" ", "_") + db_prefix = f"data/bots/{safe}" + Path(db_prefix).parent.mkdir(parents=True, exist_ok=True) + with get_conn() as conn: + conn.execute( + "INSERT INTO profiles (name, bot_type, db_prefix, config) VALUES (?,?,?,?)", + (name, bot_type, db_prefix, json.dumps(config)), + ) + row = conn.execute("SELECT * FROM profiles WHERE name=?", (name,)).fetchone() + return dict(row) + + +def update_address(profile_id: int, address: str) -> None: + with get_conn() as conn: + conn.execute("UPDATE profiles SET address=? WHERE id=?", (address, profile_id)) + + +def delete_profile(profile_id: int) -> None: + with get_conn() as conn: + conn.execute("DELETE FROM profiles WHERE id=?", (profile_id,)) diff --git a/manager/main.py b/manager/main.py new file mode 100644 index 0000000..3384f5e --- /dev/null +++ b/manager/main.py @@ -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) diff --git a/manager/profiles.py b/manager/profiles.py new file mode 100644 index 0000000..8d74462 --- /dev/null +++ b/manager/profiles.py @@ -0,0 +1,243 @@ +"""Bot lifecycle management — start, stop, status, message sending.""" + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from typing import Any + +log = logging.getLogger(__name__) + +BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"] + + +@dataclass +class RunningBot: + profile_id: int + name: str + bot_type: str + task: asyncio.Task + address: str = "" + contacts: list[dict] = field(default_factory=list) + groups: list[dict] = field(default_factory=list) + log_lines: list[str] = field(default_factory=list) + chat: Any = None # simplex_chat ChatApi instance + + +# profile_id → RunningBot +_running: dict[int, RunningBot] = {} + + +def is_running(profile_id: int) -> bool: + b = _running.get(profile_id) + return b is not None and not b.task.done() + + +def get_running(profile_id: int) -> RunningBot | None: + b = _running.get(profile_id) + if b and not b.task.done(): + return b + return None + + +def all_statuses() -> dict[int, bool]: + return {pid: is_running(pid) for pid in _running} + + +async def start_bot(profile: dict, on_address: callable) -> None: + """Start a bot for the given profile dict. Idempotent.""" + pid = profile["id"] + if is_running(pid): + return + + config = json.loads(profile.get("config") or "{}") + bot_type = profile["bot_type"] + db_prefix = profile["db_prefix"] + + task = asyncio.create_task( + _run_bot(pid, profile["name"], bot_type, db_prefix, config, on_address), + name=f"bot-{pid}-{profile['name']}", + ) + _running[pid] = RunningBot( + profile_id=pid, + name=profile["name"], + bot_type=bot_type, + task=task, + ) + log.info("Started bot %d (%s / %s)", pid, profile["name"], bot_type) + + +async def stop_bot(profile_id: int) -> None: + b = _running.get(profile_id) + if b and not b.task.done(): + b.task.cancel() + try: + await b.task + except asyncio.CancelledError: + pass + log.info("Stopped bot %d", profile_id) + + +async def send_message(profile_id: int, contact_or_group: str, text: str) -> bool: + """Send a text message from a running bot. Returns True on success.""" + b = get_running(profile_id) + if not b or not b.chat: + return False + try: + contacts = await b.chat.api_list_contacts(1) + for c in contacts: + if c["localDisplayName"] == contact_or_group: + await b.chat.api_send_text_message( + {"chatType": "direct", "chatId": c["contactId"]}, text + ) + return True + groups = await b.chat.api_list_groups(1) + for g in groups: + if g["groupInfo"]["groupProfile"]["displayName"] == contact_or_group: + await b.chat.api_send_text_message( + {"chatType": "group", "chatId": g["groupInfo"]["groupId"]}, text + ) + return True + except Exception as e: + log.error("send_message error: %s", e) + return False + + +async def _run_bot( + profile_id: int, + name: str, + bot_type: str, + db_prefix: str, + config: dict, + on_address: callable, +) -> None: + """Inner coroutine — runs the simplex-chat event loop for one profile.""" + try: + from simplex_chat import ChatApi, SqliteDb + except ImportError: + log.error("simplex-chat Python package not installed. Run: pip install simplex-chat") + return + + b = _running[profile_id] + + try: + chat = await ChatApi.init(SqliteDb(file_prefix=db_prefix)) + b.chat = chat + await chat.start_chat() + + # Create or fetch address + user = await chat.api_get_active_user() + if not user: + user = await chat.api_create_active_user( + {"displayName": name, "fullName": ""} + ) + + user_id = user["userId"] + addr = await chat.api_get_user_address(user_id) + if not addr: + addr = await chat.api_create_user_address(user_id) + + address = addr.get("connShortLink") or addr.get("connFullLink", "") + b.address = address + await on_address(profile_id, address) + + # Configure address settings based on bot type + settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}} + if bot_type == "support": + settings["businessAddress"] = True + welcome = config.get("welcome_message", f"Welcome to {name} support.") + settings["autoReply"] = {"type": "text", "text": welcome} + elif bot_type in ("echo", "broadcast", "directory", "deadmans"): + welcome = config.get("welcome_message", f"Connected to {name}.") + settings["autoReply"] = {"type": "text", "text": welcome} + + await chat.api_set_address_settings(user_id, settings) + + # Refresh contacts/groups + async def refresh() -> None: + try: + b.contacts = await chat.api_list_contacts(user_id) + b.groups = await chat.api_list_groups(user_id) + except Exception: + pass + + await refresh() + + # Event loop + while True: + evt = await chat.recv_chat_event(500_000) + if evt is None: + continue + + tag = evt.get("type", "") + b.log_lines.append(f"[{tag}]") + if len(b.log_lines) > 200: + b.log_lines = b.log_lines[-200:] + + if tag == "contactConnected": + await refresh() + ct = evt.get("contact", {}) + ct_name = ct.get("localDisplayName", "?") + _append_log(b, f"Contact connected: {ct_name}") + + if bot_type == "echo": + pass # echo handled on message + elif bot_type == "broadcast": + welcome = config.get("welcome_message", "You are subscribed.") + try: + await chat.api_send_text_message( + {"chatType": "direct", "chatId": ct["contactId"]}, welcome + ) + except Exception: + pass + + elif tag == "newChatItems": + items = evt.get("chatItems", []) + for item in items: + ci = item.get("chatItem", {}) + direction = ci.get("meta", {}).get("itemStatus", {}).get("type", "") + if direction != "sndSent": + content = ci.get("content", {}) + mc = content.get("msgContent", {}) + text = mc.get("text", "") + chat_info = item.get("chatInfo", {}) + + _append_log(b, f"Message: {text[:80]}") + + if bot_type == "echo" and text: + try: + await chat.api_send_text_reply(item, f"Echo: {text}") + except Exception: + pass + + elif bot_type == "broadcast": + publishers = config.get("publishers", []) + sender = chat_info.get("contact", {}).get("localDisplayName", "") + if sender in publishers and text: + # broadcast to all contacts + contacts = await chat.api_list_contacts(user_id) + for c in contacts: + try: + await chat.api_send_text_message( + {"chatType": "direct", "chatId": c["contactId"]}, text + ) + except Exception: + pass + + except asyncio.CancelledError: + pass + except Exception as e: + log.exception("Bot %d crashed: %s", profile_id, e) + _append_log(b, f"ERROR: {e}") + finally: + if b.chat: + try: + await b.chat.close() + except Exception: + pass + + +def _append_log(b: RunningBot, line: str) -> None: + b.log_lines.append(line) + if len(b.log_lines) > 200: + b.log_lines = b.log_lines[-200:] diff --git a/manager/requirements.txt b/manager/requirements.txt new file mode 100644 index 0000000..cf984e2 --- /dev/null +++ b/manager/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +jinja2>=3.1.4 +python-multipart>=0.0.9 +simplex-chat>=6.5.1 diff --git a/manager/start.sh b/manager/start.sh new file mode 100755 index 0000000..96ad154 --- /dev/null +++ b/manager/start.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +cd "$(dirname "$0")" + +if [ ! -d ".venv" ]; then + echo "Creating virtualenv..." + python3 -m venv .venv + .venv/bin/pip install -q -r requirements.txt +fi + +mkdir -p data/bots + +export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}" + +echo "Starting SimpleX Manager on http://0.0.0.0:8000" +echo "Token: $MANAGER_TOKEN" + +exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload diff --git a/manager/templates/base.html b/manager/templates/base.html new file mode 100644 index 0000000..0107a95 --- /dev/null +++ b/manager/templates/base.html @@ -0,0 +1,139 @@ + + + + + + {% block title %}SimpleX Manager{% endblock %} + + + {% block head %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/manager/templates/index.html b/manager/templates/index.html new file mode 100644 index 0000000..2683613 --- /dev/null +++ b/manager/templates/index.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} +{% block title %}Profiles — SimpleX Manager{% endblock %} + +{% block content %} +
+

Bot Profiles

+ +
+ +{% if profiles %} +
+ {% for p in profiles %} +
+
+
+ {{ p.name }} + {{ p.bot_type }} + + {% if p.running %}running{% else %}stopped{% endif %} + +
+
+ View + + +
+
+ {% if p.address %} +
{{ p.address }}
+ {% endif %} +
+ {% endfor %} +
+{% else %} +
+ No profiles yet. Create one to get started. +
+{% endif %} + + + +

New Bot Profile

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +{% endblock %} diff --git a/manager/templates/login.html b/manager/templates/login.html new file mode 100644 index 0000000..764e118 --- /dev/null +++ b/manager/templates/login.html @@ -0,0 +1,40 @@ + + + + + + SimpleX Manager — Login + + + +
+

SimpleX Manager

+ {% if error %}
{{ error }}
{% endif %} +
+ + + +
+
+ + diff --git a/manager/templates/profile.html b/manager/templates/profile.html new file mode 100644 index 0000000..976c354 --- /dev/null +++ b/manager/templates/profile.html @@ -0,0 +1,190 @@ +{% extends "base.html" %} +{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+ ← Profiles + / + {{ profile.name }} + {{ profile.bot_type }} + + {% if profile.running %}running{% else %}stopped{% endif %} + +
+
+ {% if profile.running %} + + {% else %} + + {% endif %} + +
+
+ +
+ +
+ +
+

Address

+ {% if profile.address %} +
{{ profile.address }}
+
+ +
+ + + {% else %} +

Start the bot to generate an address.

+ {% endif %} +
+ + +
+

Config

+ + + {% for k, v in profile.config.items() %} + + {% else %} + + {% endfor %} +
KeyValue
{{ k }}{{ v }}
No config set.
+
+
+ + +
+ +
+

Send Message

+
+
+ + + + {% for c in contacts %} +
+
+ + +
+ + +
+
+ + +
+

Contacts ({{ contacts | length }})

+ {% if contacts %} + + + {% for c in contacts %} + + + + + {% endfor %} +
NameID
{{ c.localDisplayName }}{{ c.contactId }}
+ {% else %} +

No contacts yet.

+ {% endif %} +
+ + +
+

Groups ({{ groups | length }})

+ {% if groups %} + + + {% for g in groups %} + + + + + {% endfor %} +
NameMembers
{{ g.groupInfo.groupProfile.displayName }}{{ g.members | length }}
+ {% else %} +

No groups yet.

+ {% endif %} +
+ + +
+
+

Event Log

+ +
+
{% for line in log_lines %}{{ line }} +{% endfor %}
+
+
+
+ + +{% endblock %}