Compare commits
1 Commits
ecce417f6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dab2685498 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -10,10 +10,3 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
# Downloaded libsimplex artifacts (regenerate via manager/download_sdk.sh)
|
||||
manager/libs/
|
||||
# Throwaway exploration databases
|
||||
manager/data/explore/
|
||||
# Local Claude session resume helper (machine-specific)
|
||||
manager/ai.sh
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
curl -L --progress-bar \
|
||||
"https://github.com/simplex-chat/simplex-chat-libs/releases/download/v6.5.2/simplex-chat-libs-linux-x86_64.zip" \
|
||||
-o /tmp/simplex-libs.zip && \
|
||||
unzip /tmp/simplex-libs.zip -d /tmp/simplex-libs-extracted && \
|
||||
mkdir -p ~/.cache/simplex-chat/v6.5.2/sqlite && \
|
||||
mv /tmp/simplex-libs-extracted/libs/* ~/.cache/simplex-chat/v6.5.2/sqlite/ && \
|
||||
echo "Done: $(ls ~/.cache/simplex-chat/v6.5.2/sqlite/)"
|
||||
174
manager/main.py
174
manager/main.py
@@ -27,10 +27,12 @@ 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"):
|
||||
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)
|
||||
|
||||
@@ -50,6 +52,7 @@ def _check_auth(request: Request) -> bool:
|
||||
|
||||
def _require_auth(request: Request) -> None:
|
||||
if not _check_auth(request):
|
||||
from fastapi.responses import RedirectResponse
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
@@ -59,13 +62,6 @@ def _redirect_if_unauth(request: Request):
|
||||
return None
|
||||
|
||||
|
||||
def _enrich(profiles: list[dict]) -> list[dict]:
|
||||
for p in profiles:
|
||||
p["running"] = pm.is_running(p["id"])
|
||||
p["config"] = json.loads(p.get("config") or "{}")
|
||||
return profiles
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
@@ -93,38 +89,18 @@ async def logout():
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return RedirectResponse("/users", status_code=302)
|
||||
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.USER_TYPES])
|
||||
return TEMPLATES.TemplateResponse(request, "list.html", {
|
||||
"tab": "users", "items": items, "create_types": pm.USER_TYPES,
|
||||
"nav_active": "users",
|
||||
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("/bots", response_class=HTMLResponse)
|
||||
async def bots_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.BOT_TYPES])
|
||||
return TEMPLATES.TemplateResponse(request, "list.html", {
|
||||
"tab": "bots", "items": items, "create_types": pm.BOT_TYPES,
|
||||
"nav_active": "bots",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
return TEMPLATES.TemplateResponse(request, "settings.html", {"nav_active": "settings"})
|
||||
|
||||
|
||||
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
|
||||
async def profile_page(request: Request, profile_id: int):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
@@ -138,90 +114,16 @@ async def profile_page(request: Request, profile_id: int):
|
||||
contacts = bot.contacts if bot else []
|
||||
groups = bot.groups if bot else []
|
||||
log_lines = bot.log_lines[-50:] if bot else []
|
||||
is_user = profile["bot_type"] in pm.USER_TYPES
|
||||
# Split groups by their link role: channels (observer) vs regular groups (member).
|
||||
# The is_channel flag is set during the bot's group refresh (see _classify_group).
|
||||
channels = [g for g in groups if g.get("is_channel")]
|
||||
plain_groups = [g for g in groups if not g.get("is_channel")]
|
||||
return TEMPLATES.TemplateResponse(request, "profile.html", {
|
||||
"profile": profile,
|
||||
"contacts": contacts,
|
||||
"groups": plain_groups,
|
||||
"channels": channels,
|
||||
"groups": groups,
|
||||
"log_lines": log_lines,
|
||||
"back": "/users" if is_user else "/bots",
|
||||
"nav_active": "users" if is_user else "bots",
|
||||
"bot_types": pm.BOT_TYPES,
|
||||
})
|
||||
|
||||
|
||||
def _find_chat_name(bot, chat_type: str, chat_id: int) -> str:
|
||||
"""Resolve a chat's display name from the running bot's cached lists."""
|
||||
if not bot:
|
||||
return ""
|
||||
if chat_type == "direct":
|
||||
for c in bot.contacts:
|
||||
if c["contactId"] == chat_id:
|
||||
return c["localDisplayName"]
|
||||
elif chat_type == "group":
|
||||
for g in bot.groups:
|
||||
if pm.group_id(g) == chat_id:
|
||||
return pm.group_name(g)
|
||||
return ""
|
||||
|
||||
|
||||
@app.get("/profile/{profile_id}/chat/{chat_type}/{chat_id}", response_class=HTMLResponse)
|
||||
async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id: int):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
profile = db.get_profile(profile_id)
|
||||
if not profile:
|
||||
raise HTTPException(404, "Profile not found")
|
||||
if chat_type not in ("direct", "group"):
|
||||
raise HTTPException(400, "chat_type must be 'direct' or 'group'")
|
||||
bot = pm.get_running(profile_id)
|
||||
name = _find_chat_name(bot, chat_type, chat_id) or f"#{chat_id}"
|
||||
# Is this group a channel? (affects send affordance: broadcast vs reply)
|
||||
is_channel = False
|
||||
if chat_type == "group" and bot:
|
||||
for g in bot.groups:
|
||||
if pm.group_id(g) == chat_id:
|
||||
is_channel = bool(g.get("is_channel"))
|
||||
break
|
||||
is_user = profile["bot_type"] in pm.USER_TYPES
|
||||
return TEMPLATES.TemplateResponse(request, "chat.html", {
|
||||
"profile": profile,
|
||||
"running": pm.is_running(profile_id),
|
||||
"chat_type": chat_type,
|
||||
"chat_id": chat_id,
|
||||
"chat_name": name,
|
||||
"is_channel": is_channel,
|
||||
"nav_active": "users" if is_user else "bots",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/messages")
|
||||
async def chat_messages(request: Request, profile_id: int, chat_type: str, chat_id: int):
|
||||
_require_auth(request)
|
||||
count = int(request.query_params.get("count", 50))
|
||||
try:
|
||||
messages = await pm.get_chat_history(profile_id, chat_type, chat_id, count)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"messages": messages})
|
||||
|
||||
|
||||
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/send")
|
||||
async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id: int):
|
||||
_require_auth(request)
|
||||
data = await request.json()
|
||||
text = data.get("text", "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "text required")
|
||||
ok = await pm.send_to_chat(profile_id, chat_type, chat_id, text)
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
# ── Profile API ───────────────────────────────────────────────────────────────
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/profiles")
|
||||
async def create_profile(request: Request):
|
||||
@@ -232,8 +134,8 @@ async def create_profile(request: Request):
|
||||
config = data.get("config", {})
|
||||
if not name:
|
||||
raise HTTPException(400, "name required")
|
||||
if bot_type not in pm.ALL_TYPES:
|
||||
raise HTTPException(400, f"bot_type must be one of {pm.ALL_TYPES}")
|
||||
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:
|
||||
@@ -294,50 +196,6 @@ async def send_message(request: Request, profile_id: int):
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
# ── Group / Channel API ───────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/profiles/{profile_id}/groups")
|
||||
async def create_group(request: Request, profile_id: int):
|
||||
"""Create a group (kind='group', 2-way) or channel (kind='channel', broadcast)."""
|
||||
_require_auth(request)
|
||||
profile = db.get_profile(profile_id)
|
||||
if not profile:
|
||||
raise HTTPException(404, "Profile not found")
|
||||
data = await request.json()
|
||||
name = data.get("name", "").strip()
|
||||
kind = data.get("kind", "group")
|
||||
if not name:
|
||||
raise HTTPException(400, "name required")
|
||||
try:
|
||||
if kind == "channel":
|
||||
link = await pm.create_channel(profile_id, name)
|
||||
else:
|
||||
link = await pm.create_group(profile_id, name)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": True, "link": link})
|
||||
|
||||
|
||||
@app.get("/api/profiles/{profile_id}/groups/{group_id}/members")
|
||||
async def group_members(request: Request, profile_id: int, group_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
members = await pm.get_group_members(profile_id, group_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"members": members})
|
||||
|
||||
|
||||
@app.get("/api/profiles/{profile_id}/groups/{group_id}/link")
|
||||
async def group_link(request: Request, profile_id: int, group_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
link = await pm.get_group_link(profile_id, group_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"link": link})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
@@ -8,32 +8,7 @@ from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# api_list_groups returns BARE GroupInfo dicts (verified against the live API):
|
||||
# g["groupId"], g["groupProfile"]["displayName"],
|
||||
# g["groupSummary"]["currentMembers"], g["membership"]["memberRole"]
|
||||
# There is no "groupInfo" wrapper and no "members" list in this response.
|
||||
#
|
||||
# A "channel" is a group whose join link has acceptMemberRole == "observer"
|
||||
# (joiners are read-only; only the owner broadcasts). A regular group's link
|
||||
# has role "member" (2-way). This is the only thing that distinguishes them.
|
||||
|
||||
|
||||
def group_name(g: dict) -> str:
|
||||
return g["groupProfile"]["displayName"]
|
||||
|
||||
|
||||
def group_id(g: dict) -> int:
|
||||
return g["groupId"]
|
||||
|
||||
|
||||
def group_member_count(g: dict) -> int:
|
||||
return g.get("groupSummary", {}).get("currentMembers", 0)
|
||||
|
||||
|
||||
BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"]
|
||||
USER_TYPES = ["user"]
|
||||
ALL_TYPES = BOT_TYPES + USER_TYPES
|
||||
BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -109,16 +84,18 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo
|
||||
if not b or not b.chat:
|
||||
return False
|
||||
try:
|
||||
for c in b.contacts:
|
||||
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
|
||||
for g in b.groups:
|
||||
if group_name(g) == contact_or_group:
|
||||
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": group_id(g)}, text
|
||||
{"chatType": "group", "chatId": g["groupInfo"]["groupId"]}, text
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -126,53 +103,6 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo
|
||||
return False
|
||||
|
||||
|
||||
async def send_to_chat(profile_id: int, chat_type: str, chat_id: int, text: str) -> bool:
|
||||
"""Send a message directly to a chat by its (type, id) ref. Returns True on success."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
return False
|
||||
try:
|
||||
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error("send_to_chat error: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_item(ci: dict) -> dict:
|
||||
"""Flatten a ChatItem into {id, ts, text, outgoing, sender} for the UI."""
|
||||
meta = ci.get("meta", {})
|
||||
chat_dir = ci.get("chatDir", {})
|
||||
dir_type = chat_dir.get("type", "")
|
||||
outgoing = dir_type.endswith("Snd")
|
||||
# Prefer meta.itemText; fall back to content.msgContent.text
|
||||
text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "")
|
||||
# Sender name: group messages carry the member; direct/own use a generic label
|
||||
sender = ""
|
||||
if dir_type == "groupRcv":
|
||||
sender = chat_dir.get("groupMember", {}).get("localDisplayName", "")
|
||||
return {
|
||||
"id": meta.get("itemId"),
|
||||
"ts": meta.get("itemTs", ""),
|
||||
"text": text,
|
||||
"outgoing": outgoing,
|
||||
"sender": sender,
|
||||
"deleted": "itemDeleted" in meta,
|
||||
}
|
||||
|
||||
|
||||
async def get_chat_history(
|
||||
profile_id: int, chat_type: str, chat_id: int, count: int = 50
|
||||
) -> list[dict]:
|
||||
"""Return the last `count` messages of a chat, oldest-first, normalized for the UI."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
chat = await b.chat.api_get_chat(chat_type, chat_id, count)
|
||||
items = chat.get("chatItems", [])
|
||||
return [_normalize_item(ci) for ci in items]
|
||||
|
||||
|
||||
async def _run_bot(
|
||||
profile_id: int,
|
||||
name: str,
|
||||
@@ -193,34 +123,27 @@ async def _run_bot(
|
||||
try:
|
||||
chat = await ChatApi.init(SqliteDb(file_prefix=db_prefix))
|
||||
b.chat = chat
|
||||
await chat.start_chat()
|
||||
|
||||
# libsimplex /_start requires an active user to exist first
|
||||
# Create or fetch address
|
||||
user = await chat.api_get_active_user()
|
||||
if not user:
|
||||
user = await chat.api_create_active_user(
|
||||
{"displayName": name, "fullName": ""}
|
||||
)
|
||||
|
||||
await chat.start_chat()
|
||||
|
||||
user_id = user["userId"]
|
||||
existing = await chat.api_get_user_address(user_id)
|
||||
if existing:
|
||||
# api_get_user_address returns UserContactLink; link is nested under connLinkContact
|
||||
link = existing["connLinkContact"]
|
||||
else:
|
||||
# api_create_user_address returns CreatedConnLink directly
|
||||
link = await chat.api_create_user_address(user_id)
|
||||
addr = await chat.api_get_user_address(user_id)
|
||||
if not addr:
|
||||
addr = await chat.api_create_user_address(user_id)
|
||||
|
||||
address = link.get("connShortLink") or link.get("connFullLink", "")
|
||||
address = addr.get("connShortLink") or addr.get("connFullLink", "")
|
||||
b.address = address
|
||||
await on_address(profile_id, address)
|
||||
|
||||
# Configure address settings based on profile type
|
||||
# Configure address settings based on bot type
|
||||
settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}
|
||||
if bot_type == "user":
|
||||
pass # plain user: auto-accept on, no auto-reply
|
||||
elif bot_type == "support":
|
||||
if bot_type == "support":
|
||||
settings["businessAddress"] = True
|
||||
welcome = config.get("welcome_message", f"Welcome to {name} support.")
|
||||
settings["autoReply"] = {"type": "text", "text": welcome}
|
||||
@@ -234,13 +157,9 @@ async def _run_bot(
|
||||
async def refresh() -> None:
|
||||
try:
|
||||
b.contacts = await chat.api_list_contacts(user_id)
|
||||
groups = await chat.api_list_groups(user_id)
|
||||
# Classify each group as channel (observer link) vs group (member link)
|
||||
for g in groups:
|
||||
await _classify_group(chat, g)
|
||||
b.groups = groups
|
||||
b.groups = await chat.api_list_groups(user_id)
|
||||
except Exception:
|
||||
log.exception("refresh failed for bot %d", profile_id)
|
||||
pass
|
||||
|
||||
await refresh()
|
||||
|
||||
@@ -322,84 +241,3 @@ 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:]
|
||||
|
||||
|
||||
async def _classify_group(chat: Any, g: dict) -> None:
|
||||
"""Annotate a GroupInfo in place with link info: is_channel, link.
|
||||
|
||||
A channel is a group whose join-link role is "observer". Groups with a
|
||||
"member"+ link (or no link at all) are regular 2-way groups.
|
||||
"""
|
||||
g["is_channel"] = False
|
||||
g["link"] = ""
|
||||
try:
|
||||
link_obj = await chat.api_get_group_link(g["groupId"])
|
||||
except Exception:
|
||||
return # no link → plain private group
|
||||
g["is_channel"] = link_obj.get("acceptMemberRole") == "observer"
|
||||
conn = link_obj.get("connLinkContact", {})
|
||||
g["link"] = conn.get("connShortLink") or conn.get("connFullLink", "")
|
||||
|
||||
|
||||
async def create_channel(profile_id: int, name: str) -> str:
|
||||
"""Create a group with an observer join link (a SimpleX channel). Returns the link."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
user = await b.chat.api_get_active_user()
|
||||
if not user:
|
||||
raise RuntimeError("No active user for this profile")
|
||||
info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""})
|
||||
link = await b.chat.api_create_group_link(info["groupId"], "observer")
|
||||
# Refresh cached group list (re-classifies all groups including the new channel)
|
||||
try:
|
||||
groups = await b.chat.api_list_groups(user["userId"])
|
||||
for g in groups:
|
||||
await _classify_group(b.chat, g)
|
||||
b.groups = groups
|
||||
except Exception:
|
||||
log.exception("group refresh after create_channel failed")
|
||||
return link
|
||||
|
||||
|
||||
async def create_group(profile_id: int, name: str) -> str:
|
||||
"""Create a regular 2-way group with a member join link. Returns the link."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
user = await b.chat.api_get_active_user()
|
||||
if not user:
|
||||
raise RuntimeError("No active user for this profile")
|
||||
info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""})
|
||||
link = await b.chat.api_create_group_link(info["groupId"], "member")
|
||||
try:
|
||||
groups = await b.chat.api_list_groups(user["userId"])
|
||||
for g in groups:
|
||||
await _classify_group(b.chat, g)
|
||||
b.groups = groups
|
||||
except Exception:
|
||||
log.exception("group refresh after create_group failed")
|
||||
return link
|
||||
|
||||
|
||||
async def get_group_members(profile_id: int, gid: int) -> list[dict]:
|
||||
"""Return the member list for a group/channel (excludes the owner themselves)."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
members = await b.chat.api_list_members(gid)
|
||||
return [
|
||||
{"name": m["localDisplayName"], "role": m["memberRole"], "status": m["memberStatus"]}
|
||||
for m in members
|
||||
]
|
||||
|
||||
|
||||
async def get_group_link(profile_id: int, gid: int) -> str:
|
||||
"""Return the existing join link for a group/channel (empty if none)."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
try:
|
||||
return await b.chat.api_get_group_link_str(gid)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@@ -3,16 +3,20 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Bootstrap virtualenv on first run
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "Creating virtualenv..."
|
||||
# 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 -q --upgrade pip
|
||||
.venv/bin/pip install -q -r requirements.txt
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
mkdir -p data/bots
|
||||
mkdir -p static
|
||||
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}"
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('theme');
|
||||
if(!t){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'original-dark':'original-light';}
|
||||
document.documentElement.setAttribute('data-theme',t);
|
||||
if(localStorage.getItem('sidebar-collapsed')) document.documentElement.classList.add('collapsed');
|
||||
})();
|
||||
</script>
|
||||
<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];
|
||||
@@ -22,147 +15,43 @@
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ── Original Light ─────────────────────────────────────────────── */
|
||||
[data-theme="original-light"] {
|
||||
--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);
|
||||
--btn-light-text: #fff;
|
||||
--badge-green-bg: #d1fae5;
|
||||
--badge-green-text: #065f46;
|
||||
--badge-red-bg: #fee2e2;
|
||||
--badge-red-text: #991b1b;
|
||||
: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);
|
||||
}
|
||||
|
||||
/* ── Original Dark ──────────────────────────────────────────────── */
|
||||
[data-theme="original-dark"] {
|
||||
--bg: #111827;
|
||||
--card: #0B2A59;
|
||||
--text: #f5f5f7;
|
||||
--muted: #9ca3af;
|
||||
--accent: #70F0F9;
|
||||
--green: #20BD3D;
|
||||
--red: #DD0000;
|
||||
--border: #1e3a5f;
|
||||
--shadow: none;
|
||||
--btn-light-text: #000;
|
||||
--badge-green-bg: #064e3b;
|
||||
--badge-green-text: #6ee7b7;
|
||||
--badge-red-bg: #7f1d1d;
|
||||
--badge-red-text: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Matrix ─────────────────────────────────────────────────────── */
|
||||
[data-theme="matrix"] {
|
||||
--bg: #000000;
|
||||
--card: #050d05;
|
||||
--text: #00ff41;
|
||||
--muted: #2e8b57;
|
||||
--accent: #00ff41;
|
||||
--green: #00ff41;
|
||||
--red: #ff3b3b;
|
||||
--border: #0f3d0f;
|
||||
--shadow: 0 0 14px rgba(0,255,65,0.12);
|
||||
--btn-light-text: #000000;
|
||||
--badge-green-bg: #002200;
|
||||
--badge-green-text: #00ff41;
|
||||
--badge-red-bg: #220000;
|
||||
--badge-red-text: #ff6b6b;
|
||||
@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; }
|
||||
|
||||
[data-theme="matrix"] body {
|
||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||
text-shadow: 0 0 2px rgba(0,255,65,0.4);
|
||||
}
|
||||
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; }
|
||||
|
||||
/* ── Layout: sidebar + main ─────────────────────────────────────── */
|
||||
.app { display: flex; min-height: 100vh; }
|
||||
.nav-brand { font-size: 17px; font-weight: 700; color: var(--accent); text-decoration: none; }
|
||||
|
||||
.sidebar {
|
||||
width: 220px; flex-shrink: 0;
|
||||
background: var(--card); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
transition: width 0.2s ease, transform 0.2s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
html.collapsed .sidebar { width: 64px; }
|
||||
.nav-links a { color: var(--muted); text-decoration: none; font-size: 14px; margin-left: 16px; }
|
||||
.nav-links a:hover { color: var(--accent); }
|
||||
|
||||
.nav-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 18px; font-size: 16px; font-weight: 700;
|
||||
color: var(--accent); text-decoration: none;
|
||||
border-bottom: 1px solid var(--border); white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.nav-brand .brand-icon { font-size: 18px; flex-shrink: 0; }
|
||||
|
||||
.side-nav { display: flex; flex-direction: column; padding: 8px 0; }
|
||||
.side-nav a {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 18px; color: var(--muted); text-decoration: none;
|
||||
font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.side-nav a:hover { color: var(--text); background: var(--bg); }
|
||||
.side-nav a.active { color: var(--accent); border-left-color: var(--accent); }
|
||||
.side-nav .ico { width: 20px; text-align: center; font-size: 16px; flex-shrink: 0; }
|
||||
.side-nav a.nav-sep { margin-top: 10px; padding-top: 17px; border-top: 1px solid var(--border); }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 8px 0; border-top: 1px solid var(--border); }
|
||||
.collapse-btn {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
padding: 11px 18px; background: none; border: none; cursor: pointer;
|
||||
color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.collapse-btn:hover { color: var(--text); }
|
||||
.collapse-btn .ico { width: 20px; text-align: center; flex-shrink: 0; }
|
||||
|
||||
html.collapsed .lbl, html.collapsed .brand-text { display: none; }
|
||||
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 32px 20px; width: 100%; flex: 1 0 auto; }
|
||||
|
||||
.site-footer {
|
||||
flex-shrink: 0; text-align: center;
|
||||
padding: 18px 20px; border-top: 1px solid var(--border);
|
||||
color: var(--muted); font-size: 12px; line-height: 1.6;
|
||||
}
|
||||
.site-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.site-footer a:hover { text-decoration: underline; }
|
||||
.site-footer .sep { margin: 0 8px; opacity: 0.5; }
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none; position: fixed; top: 12px; left: 12px; z-index: 40;
|
||||
width: 40px; height: 40px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--card); color: var(--text); font-size: 18px; cursor: pointer;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 45; }
|
||||
|
||||
/* ── Mobile: off-canvas sidebar ─────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; height: 100vh; width: 240px;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
html.collapsed .sidebar { width: 240px; } /* ignore collapse on mobile */
|
||||
html.collapsed .lbl, html.collapsed .brand-text { display: inline; }
|
||||
body.sidebar-open .sidebar { transform: translateX(0); }
|
||||
body.sidebar-open .backdrop { display: block; }
|
||||
.mobile-menu-btn { display: flex; }
|
||||
.collapse-btn { display: none; }
|
||||
.container { padding-top: 64px; }
|
||||
}
|
||||
.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; }
|
||||
@@ -175,15 +64,25 @@
|
||||
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: var(--btn-light-text); }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-danger { background: var(--red); color: #fff; }
|
||||
.btn-success { background: var(--green); color: var(--btn-light-text); }
|
||||
.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: var(--badge-green-bg); color: var(--badge-green-text); }
|
||||
.badge-red { background: var(--badge-red-bg); color: var(--badge-red-text); }
|
||||
.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;
|
||||
@@ -209,9 +108,6 @@
|
||||
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 6px;
|
||||
font-size: 12px; background: var(--border); color: var(--muted); }
|
||||
.tag-user { background: rgba(0,83,208,0.12); color: var(--accent); }
|
||||
[data-theme="original-dark"] .tag-user { background: rgba(112,240,249,0.12); color: var(--accent); }
|
||||
[data-theme="matrix"] .tag-user { background: rgba(0,255,65,0.12); color: var(--accent); }
|
||||
|
||||
.flex { display: flex; align-items: center; gap: 10px; }
|
||||
.flex-between { display: flex; align-items: center; justify-content: space-between; }
|
||||
@@ -236,55 +132,15 @@
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<button class="mobile-menu-btn" onclick="toggleSidebar()" aria-label="Menu">☰</button>
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<a class="nav-brand" href="/users">
|
||||
<span class="brand-icon">◆</span><span class="brand-text">SimpleX Manager</span>
|
||||
</a>
|
||||
<nav class="side-nav">
|
||||
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico">👤</span><span class="lbl">Users</span></a>
|
||||
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico">🤖</span><span class="lbl">Bots</span></a>
|
||||
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico">📁</span><span class="lbl">File upload</span></a>
|
||||
<a href="/settings" class="nav-sep {% if nav_active == 'settings' %}active{% endif %}"><span class="ico">⚙️</span><span class="lbl">Settings</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<button class="collapse-btn" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar">
|
||||
<span class="ico" id="collapse-ico">‹</span>
|
||||
</button>
|
||||
<nav class="side-nav">
|
||||
<a href="/logout"><span class="ico">⏻</span><span class="lbl">Logout</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="backdrop" id="backdrop" onclick="closeSidebar()"></div>
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
© Bournemouth Technology Ltd
|
||||
<span class="sep">·</span>
|
||||
built on © SimpleX Network
|
||||
<span class="sep">·</span>
|
||||
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a>
|
||||
</footer>
|
||||
</main>
|
||||
<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>
|
||||
<script>
|
||||
function toggleSidebar() { document.body.classList.toggle('sidebar-open'); }
|
||||
function closeSidebar() { document.body.classList.remove('sidebar-open'); }
|
||||
function toggleCollapse() {
|
||||
const collapsed = document.documentElement.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', collapsed ? '1' : '');
|
||||
const ico = document.getElementById('collapse-ico');
|
||||
if (ico) ico.textContent = collapsed ? '›' : '‹';
|
||||
}
|
||||
// Sync collapse icon with restored state on load
|
||||
(function(){
|
||||
const ico = document.getElementById('collapse-ico');
|
||||
if (ico && document.documentElement.classList.contains('collapsed')) ico.textContent = '›';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ chat_name }} — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.chat-wrap {
|
||||
display: flex; flex-direction: column;
|
||||
height: calc(100vh - 140px); min-height: 400px;
|
||||
background: var(--card); border-radius: 10px; box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.chat-head .title { font-weight: 700; font-size: 16px; }
|
||||
|
||||
.chat-log {
|
||||
flex: 1; overflow-y: auto; padding: 18px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 72%; padding: 8px 12px; border-radius: 14px;
|
||||
font-size: 14px; line-height: 1.4; word-wrap: break-word; white-space: pre-wrap;
|
||||
}
|
||||
.bubble .who { font-size: 11px; font-weight: 700; opacity: 0.7; margin-bottom: 2px; }
|
||||
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 3px; text-align: right; }
|
||||
.bubble.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--border); }
|
||||
.bubble.out { align-self: flex-end; background: var(--accent); color: var(--btn-light-text); }
|
||||
.bubble.deleted { font-style: italic; opacity: 0.5; }
|
||||
|
||||
.chat-compose {
|
||||
display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
.chat-compose textarea { resize: none; height: 42px; }
|
||||
.chat-empty { text-align: center; color: var(--muted); margin: auto; font-size: 14px; }
|
||||
.chat-banner { padding: 8px 18px; font-size: 12px; color: var(--muted);
|
||||
background: var(--bg); border-bottom: 1px solid var(--border); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex gap-8" style="margin-bottom:16px;">
|
||||
<a href="/profile/{{ profile.id }}" class="muted" style="text-decoration:none;">← {{ profile.name }}</a>
|
||||
<span class="muted">/</span>
|
||||
<strong>{{ chat_name }}</strong>
|
||||
<span class="tag">{{ 'channel' if is_channel else chat_type }}</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-wrap">
|
||||
<div class="chat-head">
|
||||
<span class="title">{{ chat_name }}</span>
|
||||
<button class="btn btn-ghost" style="padding:4px 12px;font-size:12px;"
|
||||
onclick="loadMessages(true)">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
{% if is_channel %}
|
||||
<div class="chat-banner">📢 Channel — messages you send here broadcast to all subscribers.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="chat-log" id="chat-log">
|
||||
{% if not running %}
|
||||
<div class="chat-empty">Profile is stopped. Start it to load messages.</div>
|
||||
{% else %}
|
||||
<div class="chat-empty" id="chat-loading">Loading messages…</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="chat-compose">
|
||||
<textarea id="msg-input" placeholder="{{ 'Broadcast a message…' if is_channel else 'Type a message…' }}"
|
||||
{% if not running %}disabled{% endif %}
|
||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMsg();}"></textarea>
|
||||
<button class="btn btn-primary" onclick="sendMsg()" {% if not running %}disabled{% endif %}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const PROFILE_ID = {{ profile.id }};
|
||||
const CHAT_TYPE = '{{ chat_type }}';
|
||||
const CHAT_ID = {{ chat_id }};
|
||||
const RUNNING = {{ 'true' if running else 'false' }};
|
||||
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
|
||||
let lastIds = ''; // signature of rendered messages, to skip needless re-renders
|
||||
|
||||
function fmtTs(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return '';
|
||||
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function render(messages) {
|
||||
const log = document.getElementById('chat-log');
|
||||
const sig = messages.map(m => m.id).join(',');
|
||||
if (sig === lastIds) return; // nothing new
|
||||
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||
lastIds = sig;
|
||||
|
||||
if (!messages.length) {
|
||||
log.innerHTML = '<div class="chat-empty">No messages yet.</div>';
|
||||
return;
|
||||
}
|
||||
log.innerHTML = messages.map(m => {
|
||||
const cls = 'bubble ' + (m.outgoing ? 'out' : 'in') + (m.deleted ? ' deleted' : '');
|
||||
const who = (!m.outgoing && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
|
||||
const txt = m.deleted ? '(deleted)' : escapeHtml(m.text || '');
|
||||
return `<div class="${cls}">${who}${txt}<div class="ts">${fmtTs(m.ts)}</div></div>`;
|
||||
}).join('');
|
||||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
async function loadMessages(force) {
|
||||
if (!RUNNING) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/messages?count=80`, {
|
||||
headers: {'X-Token': _token()},
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (force) lastIds = '';
|
||||
render(data.messages || []);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function sendMsg() {
|
||||
const input = document.getElementById('msg-input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/send`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
||||
body: JSON.stringify({text}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
input.value = text; // restore on failure
|
||||
alert('Failed to send');
|
||||
return;
|
||||
}
|
||||
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
|
||||
}
|
||||
|
||||
if (RUNNING) {
|
||||
loadMessages(true);
|
||||
setInterval(loadMessages, 3000); // live updates via polling
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
111
manager/templates/index.html
Normal file
111
manager/templates/index.html
Normal 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 %}
|
||||
@@ -1,183 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ tab | title }} — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.empty-state { text-align: center; padding: 56px 24px; color: var(--muted); }
|
||||
.empty-state p { margin-top: 8px; font-size: 13px; }
|
||||
|
||||
.profile-card { cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s;
|
||||
border: 1px solid transparent; }
|
||||
.profile-card:hover { border-color: var(--accent); }
|
||||
.profile-card:active { transform: translateY(1px); }
|
||||
|
||||
.addr-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
|
||||
text-decoration: none; word-break: break-all; }
|
||||
.addr-link:hover { color: var(--accent); text-decoration: underline; }
|
||||
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
|
||||
|
||||
.bot-types-card table td { vertical-align: top; }
|
||||
.bot-types-card .tag { white-space: nowrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex-between" style="margin-bottom: 24px;">
|
||||
<h1 style="margin:0;">{{ tab | title }}</h1>
|
||||
<button class="btn btn-primary" onclick="openCreate()">
|
||||
+ New {{ 'User' if tab == 'users' else 'Bot' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if tab == 'bots' %}
|
||||
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
|
||||
<table>
|
||||
<tr><td><span class="tag">echo</span></td><td class="muted">Repeats every message back to the sender — handy for testing a connection end to end.</td></tr>
|
||||
<tr><td><span class="tag">broadcast</span></td><td class="muted">Relays messages from authorized publishers out to all of the bot's contacts.</td></tr>
|
||||
<tr><td><span class="tag">support</span></td><td class="muted">Business inbox — auto-replies with a welcome message and collects incoming inquiries.</td></tr>
|
||||
<tr><td><span class="tag">directory</span></td><td class="muted">Directory service for discovering and listing groups or contacts.</td></tr>
|
||||
<tr><td><span class="tag">deadmans</span></td><td class="muted">Dead man's switch — triggers an action if expected check-ins stop arriving.</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
{% for p in items %}
|
||||
<div class="card profile-card" id="profile-{{ p.id }}"
|
||||
onclick="location.href='/profile/{{ p.id }}'">
|
||||
<div class="flex-between">
|
||||
<div class="flex gap-8">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="tag {% if p.bot_type == 'user' %}tag-user{% endif %}">{{ 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" onclick="event.stopPropagation()">
|
||||
<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="addr-row" onclick="event.stopPropagation()">
|
||||
<button class="btn btn-ghost copy-btn" title="Copy address"
|
||||
onclick="copyAddr(event, this, '{{ p.address | e }}')">📋</button>
|
||||
<a class="addr-link" href="{{ p.address }}" target="_blank" rel="noopener">{{ p.address }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state card">
|
||||
{% if tab == 'users' %}
|
||||
<strong>No users yet</strong>
|
||||
<p>Create a SimpleX user account to manage contacts and channels.</p>
|
||||
{% else %}
|
||||
<strong>No bots yet</strong>
|
||||
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Create dialog -->
|
||||
<dialog id="create-dialog">
|
||||
<h2 style="margin-bottom:20px;">New {{ 'User' if tab == 'users' else 'Bot' }}</h2>
|
||||
<form id="create-form">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
|
||||
</div>
|
||||
{% if tab == 'bots' %}
|
||||
<div class="field">
|
||||
<label>Bot Type</label>
|
||||
<select name="profile_type" id="type-select" onchange="onTypeChange()">
|
||||
{% for t in create_types %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="welcome-field">
|
||||
<label>Welcome Message</label>
|
||||
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
||||
</div>
|
||||
{% endif %}
|
||||
<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) {}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
document.getElementById('create-form').reset();
|
||||
{% if tab == 'bots' %}onTypeChange();{% endif %}
|
||||
document.getElementById('create-dialog').showModal();
|
||||
}
|
||||
|
||||
function copyAddr(ev, btn, addr) {
|
||||
ev.stopPropagation();
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = '📋', 1500);
|
||||
});
|
||||
}
|
||||
|
||||
{% if tab == 'bots' %}
|
||||
function onTypeChange() {
|
||||
const val = document.getElementById('type-select').value;
|
||||
const hide = ['echo'].includes(val); // echo has no welcome msg
|
||||
document.getElementById('welcome-field').style.display = hide ? 'none' : '';
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
{% if tab == 'users' %}
|
||||
const botType = 'user';
|
||||
const config = {};
|
||||
{% else %}
|
||||
const botType = fd.get('profile_type') || '{{ create_types[0] }}';
|
||||
const config = {};
|
||||
const welcome = fd.get('welcome_message');
|
||||
if (welcome) config.welcome_message = welcome;
|
||||
{% endif %}
|
||||
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: botType, config}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
document.getElementById('create-dialog').close();
|
||||
location.reload();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert('Error: ' + (err.detail || 'unknown'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,39 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>
|
||||
(function(){
|
||||
var t=localStorage.getItem('theme');
|
||||
if(!t){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'original-dark':'original-light';}
|
||||
document.documentElement.setAttribute('data-theme',t);
|
||||
})();
|
||||
</script>
|
||||
<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; }
|
||||
|
||||
[data-theme="original-light"] {
|
||||
--bg: #f5f5f7; --card: #fff; --text: #1d1d1f;
|
||||
--accent: #0053D0; --border: #e0e0e5;
|
||||
--btn-light-text: #fff;
|
||||
: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; }
|
||||
}
|
||||
[data-theme="original-dark"] {
|
||||
--bg: #111827; --card: #0B2A59; --text: #f5f5f7;
|
||||
--accent: #70F0F9; --border: #1e3a5f;
|
||||
--btn-light-text: #000;
|
||||
}
|
||||
[data-theme="matrix"] {
|
||||
--bg: #000; --card: #050d05; --text: #00ff41;
|
||||
--accent: #00ff41; --border: #0f3d0f;
|
||||
--btn-light-text: #000; --muted: #2e8b57;
|
||||
}
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100vh;
|
||||
display: flex; flex-direction: column; }
|
||||
.login-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
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; }
|
||||
@@ -41,35 +20,21 @@
|
||||
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: var(--btn-light-text);
|
||||
border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; }
|
||||
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; }
|
||||
.site-footer { flex-shrink: 0; text-align: center; padding: 18px 20px;
|
||||
border-top: 1px solid var(--border); color: var(--muted, #6e6e73);
|
||||
font-size: 12px; line-height: 1.6; }
|
||||
.site-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.site-footer a:hover { text-decoration: underline; }
|
||||
.site-footer .sep { margin: 0 8px; opacity: 0.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-main">
|
||||
<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>
|
||||
<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>
|
||||
<footer class="site-footer">
|
||||
© Bournemouth Technology Ltd
|
||||
<span class="sep">·</span>
|
||||
built on © SimpleX Network
|
||||
<span class="sep">·</span>
|
||||
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,34 +5,17 @@
|
||||
<style>
|
||||
.qr-wrap { text-align: center; padding: 16px; }
|
||||
.qr-wrap canvas { border-radius: 8px; }
|
||||
|
||||
.row-action { opacity: 0; transition: opacity 0.15s; }
|
||||
tr:hover .row-action { opacity: 1; }
|
||||
|
||||
.msg-btn {
|
||||
padding: 3px 10px; font-size: 12px; border-radius: 6px;
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--accent); cursor: pointer; font-weight: 600;
|
||||
font-family: inherit; white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.msg-btn:hover { background: var(--accent); color: var(--btn-light-text); }
|
||||
|
||||
.addr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
|
||||
text-decoration: none; word-break: break-all; }
|
||||
.addr-link:hover { color: var(--accent); text-decoration: underline; }
|
||||
.copy-btn { flex-shrink: 0; padding: 4px 9px; font-size: 13px; line-height: 1; }
|
||||
.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="{{ back }}" class="muted" style="text-decoration:none;">← {{ 'Users' if back == '/users' else 'Bots' }}</a>
|
||||
<a href="/" class="muted" style="text-decoration:none;">← Profiles</a>
|
||||
<span class="muted">/</span>
|
||||
<strong>{{ profile.name }}</strong>
|
||||
<span class="tag {% if profile.bot_type == 'user' %}tag-user{% endif %}">{{ profile.bot_type }}</span>
|
||||
<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>
|
||||
@@ -60,21 +43,16 @@
|
||||
<div class="card">
|
||||
<h2>Address</h2>
|
||||
{% if profile.address %}
|
||||
<div class="addr-row">
|
||||
<button class="btn btn-ghost copy-btn" title="Copy address"
|
||||
onclick="copyAddr(this, '{{ profile.address | e }}')">📋</button>
|
||||
<a class="addr-link" href="{{ profile.address }}" target="_blank" rel="noopener" id="address-text">{{ profile.address }}</a>
|
||||
</div>
|
||||
<div class="monospace muted" style="word-break:break-all; margin-bottom:12px;">{{ profile.address }}</div>
|
||||
<div class="qr-wrap">
|
||||
<canvas id="qr-canvas"></canvas>
|
||||
<p class="muted" style="margin-top:10px;">Scan QR code from mobile app to start a chat</p>
|
||||
</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 profile to generate an address.</p>
|
||||
<p class="muted">Start the bot to generate an address.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -94,19 +72,37 @@
|
||||
|
||||
<!-- 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 style="width:50px;"></th></tr>
|
||||
<tr><th>Name</th><th>ID</th></tr>
|
||||
{% for c in contacts %}
|
||||
<tr>
|
||||
<td><strong>{{ c.localDisplayName }}</strong></td>
|
||||
<td>
|
||||
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
|
||||
</td>
|
||||
<tr class="contact-row">
|
||||
<td>{{ c.localDisplayName }}</td>
|
||||
<td class="muted monospace">{{ c.contactId }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
@@ -115,64 +111,21 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Macro: one group/channel row. api_list_groups gives bare GroupInfo dicts:
|
||||
g.groupId, g.groupProfile.displayName, g.groupSummary.currentMembers.
|
||||
The verb is "Post" for channels (broadcast) and "Msg" for groups. #}
|
||||
{% macro groupRow(g) %}
|
||||
{% set name = g.groupProfile.displayName %}
|
||||
{% set gid = g.groupId %}
|
||||
{% set mcnt = g.groupSummary.currentMembers %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>
|
||||
<button class="msg-btn" style="border:none;padding:0;background:none;color:var(--accent);font-weight:600;font-size:13px;cursor:pointer;"
|
||||
onclick="loadMembers({{ gid }}, '{{ name | e }}')">{{ mcnt }}</button>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-8">
|
||||
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||
href="/profile/{{ profile.id }}/chat/group/{{ gid }}">💬 {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
|
||||
<button class="msg-btn row-action" onclick="getGroupLink({{ gid }}, this)">Link</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Groups -->
|
||||
<div class="card">
|
||||
<div class="flex-between" style="margin-bottom:12px;">
|
||||
<h2 style="margin:0;">Groups ({{ groups | length }})</h2>
|
||||
{% if profile.running %}
|
||||
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
|
||||
onclick="openCreate('group')">+ Create Group</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h2>Groups ({{ groups | length }})</h2>
|
||||
{% if groups %}
|
||||
<table>
|
||||
<tr><th>Name</th><th>Members</th><th style="width:130px;"></th></tr>
|
||||
{% for g in groups %}{{ groupRow(g) }}{% endfor %}
|
||||
<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.{% if not profile.running %} Start the profile first.{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Channels -->
|
||||
<div class="card">
|
||||
<div class="flex-between" style="margin-bottom:12px;">
|
||||
<h2 style="margin:0;">Channels ({{ channels | length }})</h2>
|
||||
{% if profile.running %}
|
||||
<button class="btn btn-primary" style="padding:6px 14px;font-size:13px;"
|
||||
onclick="openCreate('channel')">+ Create Channel</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if channels %}
|
||||
<table>
|
||||
<tr><th>Name</th><th>Subscribers</th><th style="width:130px;"></th></tr>
|
||||
{% for g in channels %}{{ groupRow(g) }}{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No channels yet.{% if not profile.running %} Start the profile first.{% endif %}</p>
|
||||
<p class="muted">No groups yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -191,221 +144,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create group/channel dialog -->
|
||||
<dialog id="ch-dialog">
|
||||
<h2 style="margin-bottom:16px;" id="ch-title">Create Group</h2>
|
||||
<p class="muted" style="margin-bottom:16px;font-size:13px;" id="ch-desc"></p>
|
||||
<div class="field">
|
||||
<label id="ch-name-label">Group Name</label>
|
||||
<input type="text" id="ch-name" placeholder="My Group" required>
|
||||
</div>
|
||||
<div id="ch-link-wrap" style="display:none;margin-bottom:12px;">
|
||||
<label>Join Link</label>
|
||||
<div class="flex gap-8">
|
||||
<input type="text" id="ch-link-out" readonly style="font-family:monospace;font-size:12px;">
|
||||
<button class="btn btn-ghost" style="white-space:nowrap;" onclick="copyChLink()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-between mt-16">
|
||||
<span id="ch-result" class="muted" style="font-size:13px;"></span>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-ghost" onclick="closeChDialog()">Close</button>
|
||||
<button class="btn btn-primary" id="ch-create-btn" onclick="createGroup()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Members dialog -->
|
||||
<dialog id="members-dialog">
|
||||
<div class="flex-between" style="margin-bottom:16px;">
|
||||
<h2 style="margin:0;">Members — <span id="members-channel-name" style="color:var(--accent);"></span></h2>
|
||||
<button class="btn btn-ghost" style="padding:4px 10px;font-size:13px;"
|
||||
onclick="document.getElementById('members-dialog').close()">✕</button>
|
||||
</div>
|
||||
<div id="members-list" style="max-height:320px;overflow-y:auto;">
|
||||
<p class="muted">Loading…</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Send message dialog -->
|
||||
<dialog id="msg-dialog">
|
||||
<h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2>
|
||||
<div class="field">
|
||||
<textarea id="msg-text" rows="4" placeholder="Type your message…"
|
||||
style="resize:vertical;"
|
||||
onkeydown="if(event.key==='Enter'&&(event.ctrlKey||event.metaKey)){sendMsg();return false;}"></textarea>
|
||||
</div>
|
||||
<div class="flex-between mt-16">
|
||||
<span id="msg-result" class="muted" style="font-size:13px;"></span>
|
||||
<div class="flex gap-8">
|
||||
<button class="btn btn-ghost" onclick="document.getElementById('msg-dialog').close()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="sendMsg()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
let msgTarget = '';
|
||||
|
||||
function openMsg(name) {
|
||||
msgTarget = name;
|
||||
document.getElementById('msg-target-label').textContent = name;
|
||||
document.getElementById('msg-text').value = '';
|
||||
document.getElementById('msg-result').textContent = '';
|
||||
const dlg = document.getElementById('msg-dialog');
|
||||
dlg.showModal();
|
||||
setTimeout(() => document.getElementById('msg-text').focus(), 50);
|
||||
}
|
||||
|
||||
async function sendMsg() {
|
||||
const text = document.getElementById('msg-text').value.trim();
|
||||
if (!text) return;
|
||||
const result = document.getElementById('msg-result');
|
||||
result.textContent = 'Sending…';
|
||||
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
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: msgTarget, text})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
document.getElementById('msg-text').value = '';
|
||||
result.textContent = '✓ Sent';
|
||||
setTimeout(() => document.getElementById('msg-dialog').close(), 800);
|
||||
} else {
|
||||
result.textContent = '✗ Failed';
|
||||
}
|
||||
}
|
||||
|
||||
function copyAddr(btn, addr) {
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = '📋', 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Groups & Channels ──────────────────────────────────────────────────────
|
||||
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
let _createKind = 'group';
|
||||
|
||||
function openCreate(kind) {
|
||||
_createKind = kind;
|
||||
const isCh = kind === 'channel';
|
||||
document.getElementById('ch-title').textContent = isCh ? 'Create Channel' : 'Create Group';
|
||||
document.getElementById('ch-desc').textContent = isCh
|
||||
? 'Observer join link — subscribers can read broadcasts but not post. Only you broadcast.'
|
||||
: 'Member join link — everyone who joins can send messages (2-way).';
|
||||
document.getElementById('ch-name-label').textContent = isCh ? 'Channel Name' : 'Group Name';
|
||||
document.getElementById('ch-name').placeholder = isCh ? 'My Channel' : 'My Group';
|
||||
document.getElementById('ch-name').value = '';
|
||||
document.getElementById('ch-link-wrap').style.display = 'none';
|
||||
document.getElementById('ch-result').textContent = '';
|
||||
const btn = document.getElementById('ch-create-btn');
|
||||
btn.disabled = false; btn.style.display = '';
|
||||
document.getElementById('ch-dialog').showModal();
|
||||
}
|
||||
|
||||
async function createGroup() {
|
||||
const name = document.getElementById('ch-name').value.trim();
|
||||
if (!name) return;
|
||||
const btn = document.getElementById('ch-create-btn');
|
||||
btn.disabled = true;
|
||||
document.getElementById('ch-result').textContent = 'Creating…';
|
||||
const resp = await fetch('/api/profiles/{{ profile.id }}/groups', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
||||
body: JSON.stringify({name, kind: _createKind}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
document.getElementById('ch-link-out').value = data.link;
|
||||
document.getElementById('ch-link-wrap').style.display = '';
|
||||
document.getElementById('ch-result').textContent = '✓ Created';
|
||||
btn.style.display = 'none';
|
||||
} else {
|
||||
document.getElementById('ch-result').textContent = '✗ ' + (data.detail || 'Failed');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyChLink() {
|
||||
const val = document.getElementById('ch-link-out').value;
|
||||
navigator.clipboard.writeText(val).then(() => {
|
||||
document.getElementById('ch-result').textContent = '✓ Copied';
|
||||
});
|
||||
}
|
||||
|
||||
function closeChDialog() {
|
||||
document.getElementById('ch-dialog').close();
|
||||
location.reload(); // refresh group/channel lists
|
||||
}
|
||||
|
||||
async function loadMembers(groupId, groupName) {
|
||||
document.getElementById('members-channel-name').textContent = groupName;
|
||||
document.getElementById('members-list').innerHTML = '<p class="muted">Loading…</p>';
|
||||
document.getElementById('members-dialog').showModal();
|
||||
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/members`, {
|
||||
headers: {'X-Token': _token()},
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.members || data.members.length === 0) {
|
||||
document.getElementById('members-list').innerHTML =
|
||||
'<p class="muted">No other members yet (you are the owner).</p>';
|
||||
return;
|
||||
}
|
||||
const rows = data.members.map(m => `
|
||||
<tr>
|
||||
<td><strong>${m.name}</strong></td>
|
||||
<td class="muted" style="font-size:12px;">${m.role}</td>
|
||||
<td class="muted" style="font-size:12px;">${m.status}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('members-list').innerHTML = `
|
||||
<table>
|
||||
<tr><th>Name</th><th>Role</th><th>Status</th></tr>
|
||||
${rows}
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function getGroupLink(groupId, btn) {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '…';
|
||||
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/link`, {
|
||||
headers: {'X-Token': _token()},
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.link) {
|
||||
await navigator.clipboard.writeText(data.link);
|
||||
btn.textContent = '✓ Copied';
|
||||
} else {
|
||||
btn.textContent = 'No link';
|
||||
}
|
||||
setTimeout(() => btn.textContent = orig, 2000);
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
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');
|
||||
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] || '';
|
||||
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 = '/');
|
||||
}).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);
|
||||
document.querySelector('[hx-get="/api/profiles/{{ profile.id }}/status"]')?.click()
|
||||
}, 10000)
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.settings-section { margin-bottom: 32px; }
|
||||
.settings-section h2 { margin-bottom: 16px; }
|
||||
|
||||
.theme-grid { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
|
||||
.theme-card {
|
||||
flex: 0 0 180px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
background: var(--card);
|
||||
}
|
||||
.theme-card:hover { border-color: var(--accent); }
|
||||
.theme-card.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0,83,208,0.18);
|
||||
}
|
||||
[data-theme="original-dark"] .theme-card.selected {
|
||||
box-shadow: 0 0 0 3px rgba(112,240,249,0.2);
|
||||
}
|
||||
[data-theme="matrix"] .theme-card.selected {
|
||||
box-shadow: 0 0 0 3px rgba(0,255,65,0.25);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
height: 96px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.preview-bar { border-radius: 4px; height: 14px; }
|
||||
.preview-bar-sm { border-radius: 4px; height: 9px; width: 60%; }
|
||||
.preview-dot { width: 20px; height: 20px; border-radius: 50%; margin-top: auto; }
|
||||
|
||||
.theme-label {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.checkmark {
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--btn-light-text);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.theme-card.selected .checkmark { opacity: 1; }
|
||||
|
||||
/* Original Light preview colors (hardcoded so visible regardless of current theme) */
|
||||
.preview-light { background: #f5f5f7; }
|
||||
.preview-light .preview-bar { background: #ffffff; }
|
||||
.preview-light .preview-bar-sm { background: #e0e0e5; }
|
||||
.preview-light .preview-dot { background: #0053D0; }
|
||||
.preview-light + .theme-label { border-color: #e0e0e5; color: #1d1d1f; background: #fff; }
|
||||
|
||||
/* Original Dark preview colors */
|
||||
.preview-dark { background: #111827; }
|
||||
.preview-dark .preview-bar { background: #0B2A59; }
|
||||
.preview-dark .preview-bar-sm { background: #1e3a5f; }
|
||||
.preview-dark .preview-dot { background: #70F0F9; }
|
||||
.preview-dark + .theme-label { border-color: #1e3a5f; color: #f5f5f7; background: #0B2A59; }
|
||||
|
||||
/* Matrix preview colors */
|
||||
.preview-matrix { background: #000000; }
|
||||
.preview-matrix .preview-bar { background: #062006; }
|
||||
.preview-matrix .preview-bar-sm { background: #0f3d0f; }
|
||||
.preview-matrix .preview-dot { background: #00ff41; box-shadow: 0 0 8px #00ff41; }
|
||||
.preview-matrix + .theme-label { border-color: #0f3d0f; color: #00ff41; background: #050d05;
|
||||
font-family: 'Consolas', monospace; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="card settings-section">
|
||||
<h2>Theme</h2>
|
||||
<div class="theme-grid">
|
||||
|
||||
<div class="theme-card" id="card-original-light" onclick="setTheme('original-light')">
|
||||
<div class="theme-preview preview-light">
|
||||
<div class="preview-bar"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-dot"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Original Light</span>
|
||||
<span class="checkmark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-card" id="card-original-dark" onclick="setTheme('original-dark')">
|
||||
<div class="theme-preview preview-dark">
|
||||
<div class="preview-bar"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-dot"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Original Dark</span>
|
||||
<span class="checkmark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-card" id="card-matrix" onclick="setTheme('matrix')">
|
||||
<div class="theme-preview preview-matrix">
|
||||
<div class="preview-bar"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-bar-sm"></div>
|
||||
<div class="preview-dot"></div>
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Matrix</span>
|
||||
<span class="checkmark">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function currentTheme() {
|
||||
return localStorage.getItem('theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme:dark)').matches ? 'original-dark' : 'original-light');
|
||||
}
|
||||
|
||||
function setTheme(t) {
|
||||
localStorage.setItem('theme', t);
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.querySelectorAll('.theme-card').forEach(c => c.classList.remove('selected'));
|
||||
const card = document.getElementById('card-' + t);
|
||||
if (card) card.classList.add('selected');
|
||||
}
|
||||
|
||||
// Mark current selection on load
|
||||
document.getElementById('card-' + currentTheme())?.classList.add('selected');
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user