Compare commits

...

2 Commits

Author SHA1 Message Date
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 1008 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,))

194
manager/main.py Normal file
View File

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

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

19
manager/start.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
if [ ! -d ".venv" ]; then
echo "Creating virtualenv..."
python3.12 -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

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

@@ -0,0 +1,139 @@
<!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>
<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,112 @@
{% 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-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
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-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
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 resp = await fetch('/api/profiles', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': document.cookie.match(/token=([^;]+)/)?.[1] || ''},
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,190 @@
{% 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-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
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-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
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-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
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 resp = await fetch('/api/profiles/{{ profile.id }}/send', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': document.cookie.match(/token=([^;]+)/)?.[1] || ''},
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
fetch('/api/profiles/{{ profile.id }}', {
method: 'DELETE',
headers: {'X-Token': document.cookie.match(/token=([^;]+)/)?.[1] || ''}
}).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 %}