Compare commits

6 Commits

Author SHA1 Message Date
Jon
dab2685498 some changes in startup 2026-06-07 20:24:03 +01:00
Jon
2d9cb4581a Fix start.sh: use python3, drop --reload, cleaner startup output 2026-06-03 01:05:01 +01:00
Jon
c54ba02253 Fix template errors: Starlette new API + remove hx-headers escaping
- TemplateResponse now uses (request, name, context) signature for Starlette 0.36+
- Replace per-button hx-headers with global htmx:configRequest token injection in base.html
- Fix JS cookie regex to handle leading semicolons correctly

Tested: login, auth redirect, profile create/view/delete all return correct status codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:03:19 +01:00
Jon
6afc464d53 Redirect unauthenticated browser requests to /login instead of returning 401 2026-06-03 00:55:48 +01:00
Jon
407a0c15e1 Use python3.12 in start.sh venv creation 2026-06-03 00:53:53 +01:00
Jon
11e799188d 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 <noreply@anthropic.com>
2026-06-03 00:53:41 +01:00
10 changed files with 1031 additions and 0 deletions

0
manager/data/.gitkeep Normal file
View File

66
manager/db.py Normal file
View File

@@ -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,))

201
manager/main.py Normal file
View File

@@ -0,0 +1,201 @@
"""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):
from fastapi.responses import RedirectResponse
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
# ── 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):
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,
})
@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 []
return TEMPLATES.TemplateResponse(request, "profile.html", {
"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)

243
manager/profiles.py Normal file
View File

@@ -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:]

5
manager/requirements.txt Normal file
View File

@@ -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

30
manager/start.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
# Bootstrap virtualenv — check for uvicorn specifically so partial installs are retried
if [ ! -f ".venv/bin/uvicorn" ]; then
echo "Setting up virtualenv..."
rm -rf .venv
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
fi
mkdir -p data/bots static
# Pre-download the libsimplex native binary so first Start doesn't stall
echo "Checking simplex-chat binary..."
.venv/bin/python -m simplex_chat install 2>/dev/null && echo " simplex binary ready." || echo " Binary not downloaded yet — use the Init button in Settings."
# Set token — override via: MANAGER_TOKEN=mysecret ./start.sh
export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}"
echo ""
echo " SimpleX Manager"
echo " URL: http://0.0.0.0:8000"
echo " Token: $MANAGER_TOKEN"
echo ""
exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000

146
manager/templates/base.html Normal file
View File

@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SimpleX Manager{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
<script>
// Inject auth token into every HTMX request automatically
document.addEventListener('htmx:configRequest', function(evt) {
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (m) evt.detail.headers['X-Token'] = m[1];
});
</script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f7;
--card: #ffffff;
--text: #1d1d1f;
--muted: #6e6e73;
--accent: #0053D0;
--green: #20BD3D;
--red: #DD0000;
--border: #e0e0e5;
--shadow: 0 2px 12px rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--card: #0B2A59;
--text: #f5f5f7;
--muted: #9ca3af;
--accent: #70F0F9;
--border: #1e3a5f;
--shadow: none;
}
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
nav { background: var(--card); border-bottom: 1px solid var(--border);
padding: 12px 24px; display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 10; }
.nav-brand { font-size: 17px; font-weight: 700; color: var(--accent); text-decoration: none; }
.nav-links a { color: var(--muted); text-decoration: none; font-size: 14px; margin-left: 16px; }
.nav-links a:hover { color: var(--accent); }
.container { max-width: 960px; margin: 0 auto; padding: 32px 20px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 24px; }
h2 { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
.card { background: var(--card); border-radius: 10px; padding: 20px;
box-shadow: var(--shadow); margin-bottom: 16px; }
.btn { display: inline-flex; align-items: center; gap: 6px;
padding: 8px 18px; border-radius: 8px; font-size: 14px; font-weight: 600;
font-family: inherit; cursor: pointer; border: none; text-decoration: none;
transition: opacity 0.15s; }
.btn:hover { opacity: 0.85; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-danger { background: var(--red); color: #fff; }
.btn-success { background: var(--green); color: #fff; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
@media (prefers-color-scheme: dark) {
.btn-primary { color: #000; }
.btn-success { color: #000; }
}
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px;
font-size: 12px; font-weight: 600; }
.badge-green { background: #d1fae5; color: #065f46; }
.badge-red { background: #fee2e2; color: #991b1b; }
@media (prefers-color-scheme: dark) {
.badge-green { background: #064e3b; color: #6ee7b7; }
.badge-red { background: #7f1d1d; color: #fca5a5; }
}
input, select, textarea {
width: 100%; padding: 9px 12px; font-size: 14px; font-family: inherit;
border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); color: var(--text); outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
label { display: block; font-size: 13px; font-weight: 600;
color: var(--muted); margin-bottom: 4px; }
.field { margin-bottom: 14px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 640px) { .grid-2 { grid-template-columns: 1fr; } }
.monospace { font-family: monospace; font-size: 12px; }
.log-box { background: #0a0a0f; color: #70F0F9; border-radius: 8px;
padding: 12px; font-family: monospace; font-size: 12px;
height: 200px; overflow-y: auto; white-space: pre-wrap; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 6px;
font-size: 12px; background: var(--border); color: var(--muted); }
.flex { display: flex; align-items: center; gap: 10px; }
.flex-between { display: flex; align-items: center; justify-content: space-between; }
.gap-8 { gap: 8px; }
.mt-8 { margin-top: 8px; }
.mt-16 { margin-top: 16px; }
.muted { color: var(--muted); font-size: 13px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; color: var(--muted); font-size: 12px; font-weight: 600;
padding: 8px 12px; border-bottom: 1px solid var(--border); }
td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
.htmx-indicator { opacity: 0; transition: opacity 0.2s; }
.htmx-request .htmx-indicator { opacity: 1; }
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border);
border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
dialog::backdrop { background: rgba(0,0,0,0.5); }
</style>
{% block head %}{% endblock %}
</head>
<body>
<nav>
<a class="nav-brand" href="/">SimpleX Manager</a>
<div class="nav-links">
<a href="/">Profiles</a>
<a href="/logout">Logout</a>
</div>
</nav>
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Profiles — SimpleX Manager{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom: 24px;">
<h1 style="margin:0">Bot Profiles</h1>
<button class="btn btn-primary" onclick="document.getElementById('create-dialog').showModal()">+ New Profile</button>
</div>
{% if profiles %}
<div id="profile-list">
{% for p in profiles %}
<div class="card" id="profile-{{ p.id }}">
<div class="flex-between">
<div class="flex gap-8">
<strong>{{ p.name }}</strong>
<span class="tag">{{ p.bot_type }}</span>
<span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
id="status-{{ p.id }}"
hx-get="/api/profiles/{{ p.id }}/status"
hx-trigger="every 5s"
hx-swap="none"
hx-on::after-request="updateStatus({{ p.id }}, event)">
{% if p.running %}running{% else %}stopped{% endif %}
</span>
</div>
<div class="flex gap-8">
<a href="/profile/{{ p.id }}" class="btn btn-ghost" style="padding: 6px 14px; font-size: 13px;">View</a>
<button class="btn btn-success" style="padding: 6px 14px; font-size: 13px;"
hx-post="/api/profiles/{{ p.id }}/start"
hx-swap="none"
onclick="this.textContent='Starting…'">Start</button>
<button class="btn btn-danger" style="padding: 6px 14px; font-size: 13px;"
hx-post="/api/profiles/{{ p.id }}/stop"
hx-swap="none"
onclick="this.textContent='Stopping…'">Stop</button>
</div>
</div>
{% if p.address %}
<div class="muted mt-8 monospace" style="word-break:break-all;">{{ p.address }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center; padding: 48px; color: var(--muted);">
No profiles yet. Create one to get started.
</div>
{% endif %}
<!-- Create dialog -->
<dialog id="create-dialog">
<h2 style="margin-bottom:20px;">New Bot Profile</h2>
<form id="create-form">
<div class="field">
<label>Name</label>
<input type="text" name="name" placeholder="My Support Bot" required>
</div>
<div class="field">
<label>Bot Type</label>
<select name="bot_type">
{% for t in bot_types %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Welcome Message</label>
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
</div>
<div class="flex gap-8 mt-16" style="justify-content: flex-end;">
<button type="button" class="btn btn-ghost" onclick="document.getElementById('create-dialog').close()">Cancel</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</dialog>
<script>
function updateStatus(id, event) {
try {
const data = JSON.parse(event.detail.xhr.responseText)
const badge = document.getElementById('status-' + id)
if (!badge) return
badge.textContent = data.running ? 'running' : 'stopped'
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red')
} catch(e) {}
}
document.getElementById('create-form').addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(e.target)
const config = {}
const welcome = fd.get('welcome_message')
if (welcome) config.welcome_message = welcome
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
const resp = await fetch('/api/profiles', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({name: fd.get('name'), bot_type: fd.get('bot_type'), config})
})
if (resp.ok) {
document.getElementById('create-dialog').close()
location.reload()
} else {
const err = await resp.json()
alert('Error: ' + (err.detail || 'unknown'))
}
})
</script>
{% endblock %}

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleX Manager — Login</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --bg: #f5f5f7; --card: #fff; --text: #1d1d1f; --accent: #0053D0; --border: #e0e0e5; }
@media (prefers-color-scheme: dark) {
:root { --bg: #111827; --card: #0B2A59; --text: #f5f5f7; --accent: #70F0F9; --border: #1e3a5f; }
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh;
display: flex; align-items: center; justify-content: center; }
.box { background: var(--card); border-radius: 12px; padding: 36px 32px;
width: 100%; max-width: 360px; box-shadow: 0 4px 24px rgba(0,0,0,0.1); }
h1 { font-size: 22px; font-weight: 700; color: var(--accent); margin-bottom: 24px; text-align: center; }
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
input { width: 100%; padding: 10px 12px; font-size: 15px; border: 1px solid var(--border);
border-radius: 8px; background: var(--bg); color: var(--text); outline: none; margin-bottom: 16px; }
input:focus { border-color: var(--accent); }
button { width: 100%; padding: 10px; background: var(--accent); color: #fff; border: none;
border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
@media (prefers-color-scheme: dark) { button { color: #000; } }
.error { color: #DD0000; font-size: 13px; margin-bottom: 12px; text-align: center; }
</style>
</head>
<body>
<div class="box">
<h1>SimpleX Manager</h1>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<label for="token">Access Token</label>
<input type="password" id="token" name="token" placeholder="Enter token…" autofocus>
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,189 @@
{% extends "base.html" %}
{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %}
{% block head %}
<style>
.qr-wrap { text-align: center; padding: 16px; }
.qr-wrap canvas { border-radius: 8px; }
.contact-row td:first-child { font-weight: 600; }
</style>
{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom: 20px;">
<div class="flex gap-8">
<a href="/" class="muted" style="text-decoration:none;">← Profiles</a>
<span class="muted">/</span>
<strong>{{ profile.name }}</strong>
<span class="tag">{{ profile.bot_type }}</span>
<span class="badge {% if profile.running %}badge-green{% else %}badge-red{% endif %}" id="status-badge">
{% if profile.running %}running{% else %}stopped{% endif %}
</span>
</div>
<div class="flex gap-8">
{% if profile.running %}
<button class="btn btn-danger"
hx-post="/api/profiles/{{ profile.id }}/stop"
hx-swap="none"
hx-on::after-request="location.reload()">Stop</button>
{% else %}
<button class="btn btn-success"
hx-post="/api/profiles/{{ profile.id }}/start"
hx-swap="none"
hx-on::after-request="location.reload()">Start</button>
{% endif %}
<button class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
<div class="grid-2">
<!-- Left column -->
<div>
<!-- Address / QR -->
<div class="card">
<h2>Address</h2>
{% if profile.address %}
<div class="monospace muted" style="word-break:break-all; margin-bottom:12px;">{{ profile.address }}</div>
<div class="qr-wrap">
<canvas id="qr-canvas"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<script>
QRCode.toCanvas(document.getElementById('qr-canvas'), {{ profile.address | tojson }}, {width: 200}, () => {})
</script>
{% else %}
<p class="muted">Start the bot to generate an address.</p>
{% endif %}
</div>
<!-- Config -->
<div class="card">
<h2>Config</h2>
<table>
<tr><th>Key</th><th>Value</th></tr>
{% for k, v in profile.config.items() %}
<tr><td>{{ k }}</td><td>{{ v }}</td></tr>
{% else %}
<tr><td colspan="2" class="muted">No config set.</td></tr>
{% endfor %}
</table>
</div>
</div>
<!-- Right column -->
<div>
<!-- Send message -->
<div class="card">
<h2>Send Message</h2>
<form id="send-form">
<div class="field">
<label>To (contact or group name)</label>
<input type="text" name="to" placeholder="Alice" list="contact-list">
<datalist id="contact-list">
{% for c in contacts %}<option value="{{ c.localDisplayName }}">{% endfor %}
{% for g in groups %}<option value="{{ g.groupInfo.groupProfile.displayName }}">{% endfor %}
</datalist>
</div>
<div class="field">
<label>Message</label>
<textarea name="text" rows="3" placeholder="Hello…"></textarea>
</div>
<button type="submit" class="btn btn-primary">Send</button>
<span id="send-result" class="muted" style="margin-left:10px;"></span>
</form>
</div>
<!-- Contacts -->
<div class="card">
<h2>Contacts ({{ contacts | length }})</h2>
{% if contacts %}
<table>
<tr><th>Name</th><th>ID</th></tr>
{% for c in contacts %}
<tr class="contact-row">
<td>{{ c.localDisplayName }}</td>
<td class="muted monospace">{{ c.contactId }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">No contacts yet.</p>
{% endif %}
</div>
<!-- Groups -->
<div class="card">
<h2>Groups ({{ groups | length }})</h2>
{% if groups %}
<table>
<tr><th>Name</th><th>Members</th></tr>
{% for g in groups %}
<tr>
<td>{{ g.groupInfo.groupProfile.displayName }}</td>
<td class="muted">{{ g.members | length }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">No groups yet.</p>
{% endif %}
</div>
<!-- Event log -->
<div class="card">
<div class="flex-between" style="margin-bottom:10px;">
<h2 style="margin:0;">Event Log</h2>
<button class="btn btn-ghost" style="font-size:12px;padding:4px 10px;"
hx-get="/api/profiles/{{ profile.id }}/status"
hx-swap="none"
hx-on::after-request="refreshLog(event)">Refresh</button>
</div>
<div class="log-box" id="log-box">{% for line in log_lines %}{{ line }}
{% endfor %}</div>
</div>
</div>
</div>
<script>
document.getElementById('send-form').addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(e.target)
const result = document.getElementById('send-result')
result.textContent = 'Sending…'
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
const resp = await fetch('/api/profiles/{{ profile.id }}/send', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({to: fd.get('to'), text: fd.get('text')})
})
const data = await resp.json()
result.textContent = data.ok ? '✓ Sent' : '✗ Failed'
setTimeout(() => result.textContent = '', 3000)
})
function refreshLog(event) {
try {
const data = JSON.parse(event.detail.xhr.responseText)
document.getElementById('log-box').textContent = data.log.join('\n')
document.getElementById('status-badge').textContent = data.running ? 'running' : 'stopped'
document.getElementById('status-badge').className = 'badge ' + (data.running ? 'badge-green' : 'badge-red')
} catch(e) {}
}
function confirmDelete() {
if (!confirm('Delete this profile? This cannot be undone.')) return
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
fetch('/api/profiles/{{ profile.id }}', {
method: 'DELETE',
headers: {'X-Token': token}
}).then(() => location.href = '/')
}
// Auto-refresh log every 10s if running
{% if profile.running %}
setInterval(() => {
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click()
}, 10000)
{% endif %}
</script>
{% endblock %}