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

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

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

Ignore runtime bot state and generated directory sites.

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

5
.gitignore vendored
View File

@@ -17,3 +17,8 @@ manager/libs/
manager/data/explore/ manager/data/explore/
# Local Claude session resume helper (machine-specific) # Local Claude session resume helper (machine-specific)
manager/ai.sh manager/ai.sh
# Runtime bot state (databases + directory registries)
manager/data/bots/
# Generated directory-bot websites (web/index.html is the master template)
/web/*/
!/web/data/

View File

@@ -61,6 +61,13 @@ def update_address(profile_id: int, address: str) -> None:
conn.execute("UPDATE profiles SET address=? WHERE id=?", (address, profile_id)) conn.execute("UPDATE profiles SET address=? WHERE id=?", (address, profile_id))
def update_config(profile_id: int, config: dict) -> None:
with get_conn() as conn:
conn.execute(
"UPDATE profiles SET config=? WHERE id=?", (json.dumps(config), profile_id)
)
def delete_profile(profile_id: int) -> None: def delete_profile(profile_id: int) -> None:
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM profiles WHERE id=?", (profile_id,)) conn.execute("DELETE FROM profiles WHERE id=?", (profile_id,))

114
manager/directory.py Normal file
View File

@@ -0,0 +1,114 @@
"""Directory bot registry: submitted groups/channels with super-user approval.
Mirrors the core of SimpleX's directory service: groups are registered (pending),
a super-user approves/rejects, and approved entries are published to the bot's
website (web/<safe>/data/listing.json) in the format index.html expects.
State is a per-bot JSON file so it survives restarts: data/bots/<safe>_directory.json
"""
import json
from datetime import datetime, timezone
from pathlib import Path
BASE = Path(__file__).parent
DATA_DIR = BASE / "data" / "bots"
WEB_DIR = BASE.parent / "web"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _state_path(safe: str) -> Path:
return DATA_DIR / f"{safe}_directory.json"
def load_state(safe: str) -> dict:
p = _state_path(safe)
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
pass
return {"seq": 0, "entries": []}
def save_state(safe: str, state: dict) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
_state_path(safe).write_text(json.dumps(state, indent=2), encoding="utf-8")
def find_by_group(state: dict, group_id: int) -> dict | None:
for e in state["entries"]:
if e["group_id"] == group_id:
return e
return None
def add_pending(
safe: str, group_id: int, display_name: str, link: str,
is_channel: bool, summary: dict, short_descr: str | None, submitted_by: str,
) -> tuple[dict, bool]:
"""Register a group as pending. Returns (entry, is_new)."""
state = load_state(safe)
existing = find_by_group(state, group_id)
if existing:
return existing, False
state["seq"] += 1
entry = {
"id": state["seq"], "group_id": group_id, "status": "pending",
"displayName": display_name, "link": link, "is_channel": bool(is_channel),
"summary": summary or {}, "shortDescr": short_descr,
"submitted_by": submitted_by, "createdAt": _now(), "activeAt": _now(),
}
state["entries"].append(entry)
save_state(safe, state)
return entry, True
def set_status(safe: str, entry_id: int, status: str) -> dict | None:
state = load_state(safe)
for e in state["entries"]:
if e["id"] == entry_id:
e["status"] = status
save_state(safe, state)
return e
return None
def entries_by_status(safe: str, status: str) -> list[dict]:
return [e for e in load_state(safe)["entries"] if e["status"] == status]
def search(safe: str, query: str) -> list[dict]:
q = query.lower().strip()
out = []
for e in entries_by_status(safe, "approved"):
if q in e["displayName"].lower() or (e.get("shortDescr") or "").lower().find(q) >= 0:
out.append(e)
return out
def publish(safe: str) -> int:
"""Write approved entries to web/<safe>/data/listing.json (website schema). Returns count."""
entries = []
for e in entries_by_status(safe, "approved"):
entry_type = {
"type": "channel" if e["is_channel"] else "group",
"summary": e.get("summary") or {},
}
if e["is_channel"]:
entry_type["groupType"] = "channel"
entries.append({
"entryType": entry_type,
"displayName": e["displayName"],
"groupLink": {"connShortLink": e["link"]} if e.get("link") else {},
"shortDescr": e.get("shortDescr"),
"createdAt": e["createdAt"],
"activeAt": e["activeAt"],
})
out = WEB_DIR / safe / "data" / "listing.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({"entries": entries}, indent=2), encoding="utf-8")
return len(entries)

View File

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

View File

@@ -3,11 +3,112 @@
import asyncio import asyncio
import json import json
import logging import logging
import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any from typing import Any
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Directory bot websites live in <repo>/web/<safe_name>/, each self-contained
# (its own index.html + data/), generated from the web/index.html template.
WEB_DIR = Path(__file__).parent.parent / "web"
def safe_name(name: str) -> str:
return name.lower().replace(" ", "_")
def generate_directory_site(name: str) -> str:
"""Generate a per-bot directory website from the web/index.html template.
Substitutes the placeholder name (SimpleXXX -> bot name) and seeds an empty
listing so the page loads cleanly. Returns the relative site path.
Each directory bot gets its own folder, so multiple can coexist (not a singleton).
"""
template = (WEB_DIR / "index.html").read_text(encoding="utf-8")
page = template.replace("SimpleXXX", name)
site = WEB_DIR / safe_name(name)
(site / "data").mkdir(parents=True, exist_ok=True)
(site / "index.html").write_text(page, encoding="utf-8")
listing = site / "data" / "listing.json"
if not listing.exists():
listing.write_text('{"entries": []}', encoding="utf-8")
return f"{safe_name(name)}/index.html"
# ── Notifications ───────────────────────────────────────────────────────────────
# In-memory, cross-account feed of received messages. Ephemeral (clears on restart).
_notifications: list[dict] = []
_notif_seq = 0
_NOTIF_MAX = 200
def record_notification(
profile_id: int, profile_name: str, chat_type: str, chat_id: int, sender: str, text: str
) -> None:
global _notif_seq
_notif_seq += 1
_notifications.append({
"id": _notif_seq,
"profile_id": profile_id,
"profile_name": profile_name,
"chat_type": chat_type,
"chat_id": chat_id,
"sender": sender,
"text": text[:140],
"ts": datetime.now(timezone.utc).isoformat(),
"read": False,
})
if len(_notifications) > _NOTIF_MAX:
del _notifications[:-_NOTIF_MAX]
def get_notifications(limit: int = 50) -> list[dict]:
"""Most-recent-first."""
return list(reversed(_notifications[-limit:]))
def unread_count() -> int:
return sum(1 for n in _notifications if not n["read"])
def mark_all_read() -> None:
for n in _notifications:
n["read"] = True
# Default system prompt for LLM-backed support bots when none is configured.
DEFAULT_SUPPORT_PROMPT = (
"You are a helpful customer-support assistant. Answer concisely and politely. "
"If you don't know something, say so rather than guessing."
)
async def llm_chat(
api_base: str, api_key: str, model: str, messages: list[dict], timeout: float = 60.0
) -> str:
"""Call an OpenAI-compatible /chat/completions endpoint and return the reply text.
Works with any provider that follows the OpenAI standard — Grok (api.x.ai/v1),
Ollama (localhost:11434/v1), OpenAI (api.openai.com/v1), etc. Only the base URL,
key and model differ. `api_base` should include the version path (e.g. .../v1).
"""
url = api_base.rstrip("/") + "/chat/completions"
body = json.dumps({"model": model, "messages": messages}).encode("utf-8")
def _call() -> str:
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
if api_key:
req.add_header("Authorization", f"Bearer {api_key}")
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - user-configured endpoint
return resp.read().decode("utf-8")
raw = await asyncio.to_thread(_call)
data = json.loads(raw)
return data["choices"][0]["message"]["content"]
# api_list_groups returns BARE GroupInfo dicts (verified against the live API): # api_list_groups returns BARE GroupInfo dicts (verified against the live API):
# g["groupId"], g["groupProfile"]["displayName"], # g["groupId"], g["groupProfile"]["displayName"],
@@ -47,6 +148,8 @@ class RunningBot:
groups: list[dict] = field(default_factory=list) groups: list[dict] = field(default_factory=list)
log_lines: list[str] = field(default_factory=list) log_lines: list[str] = field(default_factory=list)
chat: Any = None # simplex_chat ChatApi instance chat: Any = None # simplex_chat ChatApi instance
# Per-contact LLM conversation history (contactId → [{role, content}, ...])
histories: dict[int, list[dict]] = field(default_factory=dict)
# profile_id → RunningBot # profile_id → RunningBot
@@ -127,16 +230,35 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo
async def send_to_chat(profile_id: int, chat_type: str, chat_id: int, text: str) -> bool: 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.""" """Send a message directly to a chat by its (type, id) ref. Raises on failure."""
b = get_running(profile_id) b = get_running(profile_id)
if not b or not b.chat: if not b or not b.chat:
return False raise RuntimeError("Profile is not running")
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text)
return True
async def refresh_lists(profile_id: int) -> None:
"""Re-fetch contacts and groups (with channel classification) for a running profile.
Called when rendering the profile page so member counts and lists are current —
group joins don't emit contactConnected, so the cached lists would otherwise stale.
"""
b = get_running(profile_id)
if not b or not b.chat:
return
try: try:
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text) user = await b.chat.api_get_active_user()
return True if not user:
except Exception as e: return
log.error("send_to_chat error: %s", e) uid = user["userId"]
return False b.contacts = await b.chat.api_list_contacts(uid)
groups = await b.chat.api_list_groups(uid)
for g in groups:
await _classify_group(b.chat, g)
b.groups = groups
except Exception:
log.exception("refresh_lists failed for %d", profile_id)
def _normalize_item(ci: dict) -> dict: def _normalize_item(ci: dict) -> dict:
@@ -173,6 +295,34 @@ async def get_chat_history(
return [_normalize_item(ci) for ci in items] return [_normalize_item(ci) for ci in items]
async def clear_chat(profile_id: int, chat_type: str, chat_id: int) -> bool:
"""Clear a conversation's messages but keep the contact/group (delete mode 'messages')."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
ref = ("@" if chat_type == "direct" else "#") + str(chat_id)
r = await b.chat.send_chat_cmd(f"/_delete {ref} messages")
if isinstance(r, dict) and r.get("type") == "chatCmdError":
raise RuntimeError(f"clear failed: {r}")
return True
async def delete_contact(profile_id: int, contact_id: int) -> bool:
"""Delete a contact entirely (delete mode 'full', notifies the contact)."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
await b.chat.api_delete_chat("direct", contact_id, {"type": "full", "notify": True})
# Refresh the cached contact list so the row disappears
try:
user = await b.chat.api_get_active_user()
if user:
b.contacts = await b.chat.api_list_contacts(user["userId"])
except Exception:
pass
return True
async def _run_bot( async def _run_bot(
profile_id: int, profile_id: int,
name: str, name: str,
@@ -197,13 +347,13 @@ async def _run_bot(
# libsimplex /_start requires an active user to exist first # libsimplex /_start requires an active user to exist first
user = await chat.api_get_active_user() user = await chat.api_get_active_user()
if not user: if not user:
user = await chat.api_create_active_user( user = await chat.api_create_active_user(_profile_dict(name, config))
{"displayName": name, "fullName": ""}
)
await chat.start_chat() await chat.start_chat()
user_id = user["userId"] user_id = user["userId"]
# Sync profile from config so edits made while stopped take effect on start
await _sync_profile(chat, user_id, name, config)
existing = await chat.api_get_user_address(user_id) existing = await chat.api_get_user_address(user_id)
if existing: if existing:
# api_get_user_address returns UserContactLink; link is nested under connLinkContact # api_get_user_address returns UserContactLink; link is nested under connLinkContact
@@ -244,9 +394,35 @@ async def _run_bot(
await refresh() await refresh()
# Dead man's switch state: fire `message` if no check-in within checkin_hours.
dms_interval_s = float(config.get("checkin_hours", 24)) * 3600
dms_last_checkin = datetime.now(timezone.utc)
dms_fired = False
if bot_type == "deadmans":
_append_log(b, f"Dead man's switch armed — deadline in {config.get('checkin_hours', 24)}h")
dir_tick = 0 # directory bots periodically scan for newly-added groups
# Event loop # Event loop
while True: while True:
evt = await chat.recv_chat_event(500_000) evt = await chat.recv_chat_event(500_000)
# Directory bot: ~every 30s, refresh groups and register/auto-join new ones
if bot_type == "directory":
dir_tick += 1
if dir_tick >= 60:
dir_tick = 0
await refresh()
await _directory_register_new_groups(b, chat, config, name)
# Dead man's switch: fire once if the check-in window has elapsed
if bot_type == "deadmans" and not dms_fired:
elapsed = (datetime.now(timezone.utc) - dms_last_checkin).total_seconds()
if elapsed > dms_interval_s:
dms_fired = True
n = await _fire_deadmans(chat, user_id, config)
_append_log(b, f"Dead man's switch FIRED → notified {n} contact(s)")
if evt is None: if evt is None:
continue continue
@@ -276,8 +452,11 @@ async def _run_bot(
items = evt.get("chatItems", []) items = evt.get("chatItems", [])
for item in items: for item in items:
ci = item.get("chatItem", {}) ci = item.get("chatItem", {})
direction = ci.get("meta", {}).get("itemStatus", {}).get("type", "") # Robust incoming-vs-outgoing: chatDir ".../Rcv" = received,
if direction != "sndSent": # ".../Snd" = sent by us. Avoids replying to our own messages.
chat_dir = ci.get("chatDir", {}).get("type", "")
incoming = chat_dir.endswith("Rcv")
if incoming:
content = ci.get("content", {}) content = ci.get("content", {})
mc = content.get("msgContent", {}) mc = content.get("msgContent", {})
text = mc.get("text", "") text = mc.get("text", "")
@@ -285,12 +464,58 @@ async def _run_bot(
_append_log(b, f"Message: {text[:80]}") _append_log(b, f"Message: {text[:80]}")
# Cross-account notification for any received message
if text:
ci_type = chat_info.get("type")
if ci_type == "direct":
ct = chat_info.get("contact", {})
_notify_ref = ("direct", ct.get("contactId"), ct.get("localDisplayName", ""))
elif ci_type == "group":
gm = ci.get("chatDir", {}).get("groupMember", {}) or {}
_notify_ref = (
"group",
chat_info.get("groupInfo", {}).get("groupId"),
gm.get("localDisplayName", ""),
)
else:
_notify_ref = (None, None, "")
if _notify_ref[1] is not None:
record_notification(
profile_id, name, _notify_ref[0], _notify_ref[1],
_notify_ref[2], text,
)
if bot_type == "echo" and text: if bot_type == "echo" and text:
try: try:
await chat.api_send_text_reply(item, f"Echo: {text}") await chat.api_send_text_reply(item, f"Echo: {text}")
except Exception: except Exception:
pass pass
elif bot_type == "support" and text:
await _handle_support_message(
b, chat, config, item, chat_info, text
)
elif bot_type == "directory" and text:
await _handle_directory_message(
b, chat, config, name, item, chat_info, text
)
elif bot_type == "deadmans" and text:
owner = config.get("owner")
sender = chat_info.get("contact", {}).get("localDisplayName", "")
# Any contact checks in, unless an owner is configured
if not owner or sender == owner:
dms_last_checkin = datetime.now(timezone.utc)
dms_fired = False
hrs = config.get("checkin_hours", 24)
try:
await chat.api_send_text_reply(
item, f"✓ Check-in received. Switch reset — next deadline in {hrs}h."
)
except Exception:
pass
elif bot_type == "broadcast": elif bot_type == "broadcast":
publishers = config.get("publishers", []) publishers = config.get("publishers", [])
sender = chat_info.get("contact", {}).get("localDisplayName", "") sender = chat_info.get("contact", {}).get("localDisplayName", "")
@@ -324,6 +549,331 @@ def _append_log(b: RunningBot, line: str) -> None:
b.log_lines = b.log_lines[-200:] b.log_lines = b.log_lines[-200:]
async def _handle_directory_message(
b: RunningBot, chat: Any, config: dict, name: str, item: dict, chat_info: dict, text: str
) -> None:
"""Directory bot: super-user approval commands, and search for everyone else."""
import directory as _dir
safe = safe_name(name)
superusers = config.get("superusers", []) or []
sender = chat_info.get("contact", {}).get("localDisplayName", "")
is_super = sender in superusers
t = text.strip()
async def reply(msg: str) -> None:
try:
await chat.api_send_text_reply(item, msg)
except Exception:
pass
if t.startswith("/"):
parts = t.split()
cmd = parts[0].lower()
if cmd == "/help":
await reply("Send a search term to find groups. Admins: /list, /approve <id>, /reject <id>.")
elif cmd == "/list":
if not is_super:
await reply("Only admins can list pending submissions.")
return
pend = _dir.entries_by_status(safe, "pending")
if not pend:
await reply("No pending submissions.")
else:
await reply("Pending:\n" + "\n".join(
f"#{e['id']} {e['displayName']} ({'channel' if e['is_channel'] else 'group'})" for e in pend
))
elif cmd in ("/approve", "/reject"):
if not is_super:
await reply("Only admins can approve or reject.")
return
if len(parts) < 2 or not parts[1].isdigit():
await reply(f"Usage: {cmd} <id>")
return
eid = int(parts[1])
status = "approved" if cmd == "/approve" else "rejected"
e = _dir.set_status(safe, eid, status)
if not e:
await reply(f"No submission #{eid}.")
return
if status == "approved":
_dir.publish(safe)
await reply(f"✓ Approved #{eid} '{e['displayName']}' — now listed on the directory site.")
else:
await reply(f"Rejected #{eid} '{e['displayName']}'.")
else:
await reply("Unknown command. Send /help for options.")
else:
results = _dir.search(safe, t)
if not results:
await reply(f"No groups found for '{t}'. Send /help for options.")
else:
await reply("Found:\n" + "\n".join(f"{e['displayName']}: {e['link'] or '(no link)'}" for e in results[:10]))
async def _directory_register_new_groups(
b: RunningBot, chat: Any, config: dict, name: str
) -> None:
"""Auto-join groups the directory bot was added to, and register them as pending."""
import directory as _dir
safe = safe_name(name)
superusers = config.get("superusers", []) or []
for g in b.groups:
gid = group_id(g)
membership = g.get("membership") or {}
status = membership.get("memberStatus", "")
role = membership.get("memberRole", "")
if status == "invited":
try:
await chat.api_join_group(gid) # someone added the bot; accept
except Exception:
pass
continue # registered on a later tick once it syncs
if role != "owner" and not _dir.find_by_group(_dir.load_state(safe), gid):
link = ""
try:
link = await chat.api_get_group_link_str(gid)
except Exception:
pass
entry, is_new = _dir.add_pending(
safe, gid, group_name(g), link, bool(g.get("is_channel")),
g.get("groupSummary") or {}, (g.get("groupProfile") or {}).get("shortDescr"), "group",
)
if is_new:
_append_log(b, f"Directory: registered pending #{entry['id']} '{entry['displayName']}'")
for c in b.contacts:
if c["localDisplayName"] in superusers:
try:
await chat.api_send_text_message(
{"chatType": "direct", "chatId": c["contactId"]},
f"New directory submission #{entry['id']}: '{entry['displayName']}'. "
f"/approve {entry['id']} or /reject {entry['id']}.",
)
except Exception:
pass
async def _fire_deadmans(chat: Any, user_id: int, config: dict) -> int:
"""Send the dead-man message to recipients (named, or all contacts). Returns count sent."""
message = config.get("message") or "Dead man's switch triggered — no check-in was received."
recipients = config.get("recipients") or [] # list of display names; empty = all contacts
try:
contacts = await chat.api_list_contacts(user_id)
except Exception:
return 0
targets = contacts if not recipients else [c for c in contacts if c["localDisplayName"] in recipients]
sent = 0
for c in targets:
try:
await chat.api_send_text_message({"chatType": "direct", "chatId": c["contactId"]}, message)
sent += 1
except Exception:
pass
return sent
async def _handle_support_message(
b: RunningBot, chat: Any, config: dict, item: dict, chat_info: dict, text: str
) -> None:
"""Answer an incoming support message via the configured OpenAI-compatible LLM.
If no api_base is configured the bot stays silent (the static welcome auto-reply
has already greeted the contact) — so support bots without an LLM behave as before.
"""
api_base = (config.get("api_base") or "").strip()
if not api_base:
return # no LLM configured for this bot
contact_id = chat_info.get("contact", {}).get("contactId")
system_prompt = config.get("system_prompt") or DEFAULT_SUPPORT_PROMPT
model = config.get("model") or "grok-2"
api_key = config.get("api_key") or ""
# Maintain a short rolling history per contact for conversational context.
hist = b.histories.setdefault(contact_id, [])
hist.append({"role": "user", "content": text})
if len(hist) > 20:
del hist[:-20]
messages = [{"role": "system", "content": system_prompt}, *hist]
try:
reply = await llm_chat(api_base, api_key, model, messages)
except Exception as e:
log.error("support LLM error: %s", e)
_append_log(b, f"LLM error: {e}")
try:
await chat.api_send_text_reply(
item, "Sorry, I'm having trouble responding right now. Please try again shortly."
)
except Exception:
pass
return
hist.append({"role": "assistant", "content": reply})
_append_log(b, f"LLM reply: {reply[:80]}")
try:
await chat.api_send_text_reply(item, reply)
except Exception:
log.exception("failed to send support reply")
async def global_status() -> dict:
"""Aggregate manager-wide status: running profiles, totals, and server config.
Server/network info (SMP+XFTP counts, operators) is read from the first running
profile — these are the shared SimpleX presets, so one profile represents all.
"""
running = [b for b in _running.values() if not b.task.done()]
status = {
"profiles_running": len(running),
"contacts": sum(len(b.contacts) for b in running),
"groups": sum(len(b.groups) for b in running),
"smp_servers": 0,
"xftp_servers": 0,
"operators": [],
"proxy_mode": "",
}
for b in running:
if not b.chat:
continue
try:
user = await b.chat.api_get_active_user()
r = await b.chat.send_chat_cmd(f"/_servers {user['userId']}")
for op in r.get("userServers", []):
o = op.get("operator") or {}
if o.get("enabled") and o.get("tradeName"):
status["operators"].append(o["tradeName"])
status["smp_servers"] += sum(
1 for s in op.get("smpServers", []) if s.get("enabled") and not s.get("deleted")
)
status["xftp_servers"] += sum(
1 for s in op.get("xftpServers", []) if s.get("enabled") and not s.get("deleted")
)
nc = await b.chat.send_chat_cmd("/network")
status["proxy_mode"] = nc.get("networkConfig", {}).get("smpProxyMode", "")
break # one running profile is representative
except Exception:
log.exception("global_status server read failed")
continue
return status
def _server_host(s: str) -> str:
"""Extract the readable host from a server URI like smp://key@host1,host2.onion."""
try:
after = s.split("://", 1)[1] if "://" in s else s
if "@" in after:
after = after.split("@", 1)[1]
return after.split(",")[0]
except Exception:
return s
def _server_row(s: dict) -> dict:
return {
"host": _server_host(s.get("server", "")),
"enabled": bool(s.get("enabled")),
"preset": bool(s.get("preset")),
"deleted": bool(s.get("deleted")),
}
def _first_running_chat():
for b in _running.values():
if not b.task.done() and b.chat:
return b
return None
async def get_servers_detail() -> dict:
"""Full per-operator SMP/XFTP server breakdown + network config (first running profile)."""
b = _first_running_chat()
if not b:
return {"profile_name": None, "operators": [], "network": {}}
try:
user = await b.chat.api_get_active_user()
r = await b.chat.send_chat_cmd(f"/_servers {user['userId']}")
nc = await b.chat.send_chat_cmd("/network")
operators = []
for op in r.get("userServers", []):
o = op.get("operator") or {}
operators.append({
"name": o.get("tradeName") or "Custom",
"enabled": bool(o.get("enabled")),
"smp": [_server_row(s) for s in op.get("smpServers", [])],
"xftp": [_server_row(s) for s in op.get("xftpServers", [])],
})
return {"profile_name": b.name, "operators": operators, "network": nc.get("networkConfig", {})}
except Exception:
log.exception("get_servers_detail failed")
return {"profile_name": None, "operators": [], "network": {}}
async def get_network_config() -> dict:
"""Just the networkConfig (proxy/host/session modes) from the first running profile."""
b = _first_running_chat()
if not b:
return {}
try:
nc = await b.chat.send_chat_cmd("/network")
return nc.get("networkConfig", {})
except Exception:
log.exception("get_network_config failed")
return {}
def _profile_dict(name: str, config: dict) -> dict:
"""Build a SimpleX Profile dict (displayName/fullName/shortDescr/image) from config."""
profile: dict = {"displayName": name, "fullName": config.get("full_name", "")}
if config.get("bio"):
profile["shortDescr"] = config["bio"]
if config.get("avatar"):
profile["image"] = config["avatar"] # base64 data URI
return profile
async def _sync_profile(chat: Any, user_id: int, name: str, config: dict) -> None:
"""Push the config-derived profile to the live account. No-op if unchanged."""
try:
await chat.api_update_profile(user_id, _profile_dict(name, config))
except Exception:
log.exception("profile sync failed")
async def update_profile(
profile_id: int, full_name: str | None, bio: str | None, avatar: str | None
) -> dict:
"""Persist profile edits to the manager DB and apply them live if running.
`avatar` is a base64 data URI; pass None to leave the existing avatar unchanged,
or an empty string to remove it. Returns the updated config dict.
"""
import db as _db
prof = _db.get_profile(profile_id)
if not prof:
raise RuntimeError("Profile not found")
config = json.loads(prof.get("config") or "{}")
if full_name is not None:
config["full_name"] = full_name
if bio is not None:
config["bio"] = bio
if avatar is not None:
if avatar:
config["avatar"] = avatar
else:
config.pop("avatar", None)
_db.update_config(profile_id, config)
b = get_running(profile_id)
if b and b.chat:
user = await b.chat.api_get_active_user()
if user:
await _sync_profile(b.chat, user["userId"], prof["name"], config)
return config
async def _classify_group(chat: Any, g: dict) -> None: async def _classify_group(chat: Any, g: dict) -> None:
"""Annotate a GroupInfo in place with link info: is_channel, link. """Annotate a GroupInfo in place with link info: is_channel, link.
@@ -383,15 +933,61 @@ async def create_group(profile_id: int, name: str) -> str:
async def get_group_members(profile_id: int, gid: int) -> list[dict]: async def get_group_members(profile_id: int, gid: int) -> list[dict]:
"""Return the member list for a group/channel (excludes the owner themselves).""" """Return the member list for a group/channel.
api_list_members excludes the user themselves, so we prepend our own
membership — this makes the dialog count match groupSummary.currentMembers.
"""
b = get_running(profile_id) b = get_running(profile_id)
if not b or not b.chat: if not b or not b.chat:
raise RuntimeError("Profile is not running") raise RuntimeError("Profile is not running")
members = await b.chat.api_list_members(gid) members = await b.chat.api_list_members(gid)
return [ result = [
{"name": m["localDisplayName"], "role": m["memberRole"], "status": m["memberStatus"]} {"name": m["localDisplayName"], "role": m["memberRole"], "status": m["memberStatus"]}
for m in members for m in members
] ]
# Prepend ourselves (the owner), pulled from the cached group's membership.
for g in b.groups:
if group_id(g) == gid:
me = g.get("membership") or {}
if me:
result.insert(0, {
"name": me.get("localDisplayName", "you") + " (you)",
"role": me.get("memberRole", "owner"),
"status": me.get("memberStatus", ""),
})
break
return result
async def join_group(profile_id: int, gid: int) -> bool:
"""Accept a pending group invitation (memberStatus 'invited' -> joined)."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
await b.chat.api_join_group(gid)
await refresh_lists(profile_id)
return True
async def leave_group(profile_id: int, gid: int) -> bool:
"""Leave a group/channel (api_leave_group), then refresh the cached lists."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
await b.chat.api_leave_group(gid)
await refresh_lists(profile_id)
return True
async def delete_group(profile_id: int, gid: int) -> bool:
"""Delete a group/channel entirely (owner action; delete mode 'full', notifies members)."""
b = get_running(profile_id)
if not b or not b.chat:
raise RuntimeError("Profile is not running")
await b.chat.api_delete_chat("group", gid, {"type": "full", "notify": True})
await refresh_lists(profile_id)
return True
async def get_group_link(profile_id: int, gid: int) -> str: async def get_group_link(profile_id: int, gid: int) -> str:

View File

@@ -23,4 +23,4 @@ echo " URL: http://0.0.0.0:8000"
echo " Token: $MANAGER_TOKEN" echo " Token: $MANAGER_TOKEN"
echo "" echo ""
exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -107,17 +107,39 @@
.side-nav { display: flex; flex-direction: column; padding: 8px 0; } .side-nav { display: flex; flex-direction: column; padding: 8px 0; }
.side-nav a { .side-nav a {
position: relative;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 11px 18px; color: var(--muted); text-decoration: none; padding: 11px 18px; color: var(--muted); text-decoration: none;
font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden;
border-left: 3px solid transparent; border-left: 3px solid transparent;
} }
.notif-badge {
margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px;
border-radius: 9px; background: var(--red); color: #fff;
font-size: 11px; font-weight: 700;
display: inline-flex; align-items: center; justify-content: center;
}
html.collapsed .notif-badge {
position: absolute; top: 5px; right: 8px; margin: 0;
min-width: 16px; height: 16px; font-size: 10px; padding: 0 4px;
}
.side-nav a:hover { color: var(--text); background: var(--bg); } .side-nav a:hover { color: var(--text); background: var(--bg); }
.side-nav a.active { color: var(--accent); border-left-color: var(--accent); } .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 .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-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); } .side-foot { margin-top: auto; padding: 8px 0; border-top: 1px solid var(--border); }
.side-status { padding: 10px 18px 12px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--border); }
.side-status .ss-title { font-weight: 700; text-transform: uppercase; font-size: 10px;
letter-spacing: 0.5px; margin-bottom: 7px; opacity: 0.7; }
.side-status .ss-row { display: flex; align-items: center; gap: 6px; margin-top: 3px;
white-space: nowrap; overflow: hidden; }
.ss-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
.ss-dot.online { background: var(--green); box-shadow: 0 0 5px var(--green); }
.ss-dot.offline { background: var(--red); }
html.collapsed .side-status { display: none; }
.collapse-btn { .collapse-btn {
display: flex; align-items: center; gap: 12px; width: 100%; display: flex; align-items: center; gap: 12px; width: 100%;
padding: 11px 18px; background: none; border: none; cursor: pointer; padding: 11px 18px; background: none; border: none; cursor: pointer;
@@ -246,9 +268,17 @@
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico">👤</span><span class="lbl">Users</span></a> <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="/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="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico">📁</span><span class="lbl">File upload</span></a>
<a href="/notifications" class="nav-sep {% if nav_active == 'notifications' %}active{% endif %}"><span class="ico">🔔</span><span class="lbl">Notifications</span><span class="notif-badge" id="notif-badge" style="display:none;"></span></a>
<a href="/settings" class="nav-sep {% if nav_active == 'settings' %}active{% endif %}"><span class="ico">⚙️</span><span class="lbl">Settings</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> </nav>
<div class="side-foot"> <div class="side-foot">
<a href="/network" class="side-status" id="side-status" title="View SimpleX network &amp; servers"
style="display:block;text-decoration:none;{% if nav_active == 'network' %}background:var(--bg);{% endif %}">
<div class="ss-title">Network </div>
<div class="ss-row"><span class="ss-dot" id="ss-dot"></span><span id="ss-running">/</span>&nbsp;running</div>
<div class="ss-row" id="ss-servers">📡 </div>
<div class="ss-row" id="ss-ops" style="opacity:0.8;"></div>
</a>
<button class="collapse-btn" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar"> <button class="collapse-btn" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar">
<span class="ico" id="collapse-ico"></span> <span class="ico" id="collapse-ico"></span>
</button> </button>
@@ -285,6 +315,41 @@
const ico = document.getElementById('collapse-ico'); const ico = document.getElementById('collapse-ico');
if (ico && document.documentElement.classList.contains('collapsed')) ico.textContent = ''; if (ico && document.documentElement.classList.contains('collapsed')) ico.textContent = '';
})(); })();
// Poll for unread notifications and update the sidebar badge
async function pollNotifications() {
try {
const t = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
const r = await fetch('/api/notifications', { headers: { 'X-Token': t } });
if (!r.ok) return;
const d = await r.json();
const b = document.getElementById('notif-badge');
if (!b) return;
if (d.unread > 0) { b.textContent = d.unread > 99 ? '99+' : d.unread; b.style.display = 'inline-flex'; }
else { b.style.display = 'none'; }
} catch (e) {}
}
pollNotifications();
setInterval(pollNotifications, 5000);
// Poll global SimpleX/network status for the sidebar widget
async function pollStatus() {
try {
const t = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
const r = await fetch('/api/status', { headers: { 'X-Token': t } });
if (!r.ok) return;
const d = await r.json();
document.getElementById('ss-running').textContent = d.profiles_running + '/' + d.profiles_total;
const dot = document.getElementById('ss-dot');
dot.className = 'ss-dot ' + (d.online ? 'online' : 'offline');
document.getElementById('ss-servers').textContent =
d.online ? `📡 ${d.smp_servers} SMP · ${d.xftp_servers} XFTP` : 'no profile running';
document.getElementById('ss-ops').textContent =
(d.operators && d.operators.length) ? d.operators.join(', ') : '';
} catch (e) {}
}
pollStatus();
setInterval(pollStatus, 15000);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -140,7 +140,7 @@ async function sendMsg() {
const data = await resp.json(); const data = await resp.json();
if (!data.ok) { if (!data.ok) {
input.value = text; // restore on failure input.value = text; // restore on failure
alert('Failed to send'); alert('Failed to send: ' + (data.error || data.detail || 'unknown error'));
return; return;
} }
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly

View File

@@ -49,6 +49,10 @@
onclick="location.href='/profile/{{ p.id }}'"> onclick="location.href='/profile/{{ p.id }}'">
<div class="flex-between"> <div class="flex-between">
<div class="flex gap-8"> <div class="flex gap-8">
{% if p.config.avatar %}
<img src="{{ p.config.avatar }}" alt=""
style="width:32px;height:32px;border-radius:50%;object-fit:cover;border:1px solid var(--border);flex-shrink:0;">
{% endif %}
<strong>{{ p.name }}</strong> <strong>{{ p.name }}</strong>
<span class="tag {% if p.bot_type == 'user' %}tag-user{% endif %}">{{ p.bot_type }}</span> <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 %}" <span class="badge {% if p.running %}badge-green{% else %}badge-red{% endif %}"
@@ -98,6 +102,17 @@
<label>Name</label> <label>Name</label>
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required> <input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
</div> </div>
<div class="field">
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
<textarea name="bio" rows="2" placeholder="A short description shown on the profile"></textarea>
</div>
<div class="field">
<label>Avatar <span class="muted" style="font-weight:400;">(optional image)</span></label>
<div class="flex gap-8">
<img id="avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
<input type="file" name="avatar_file" accept="image/*" onchange="onAvatarChange(this)" style="flex:1;">
</div>
</div>
{% if tab == 'bots' %} {% if tab == 'bots' %}
<div class="field"> <div class="field">
<label>Bot Type</label> <label>Bot Type</label>
@@ -111,6 +126,63 @@
<label>Welcome Message</label> <label>Welcome Message</label>
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?"> <input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
</div> </div>
<div id="support-fields" style="display:none;">
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
<p class="muted" style="margin-bottom:12px;">
LLM backend (OpenAI-compatible). Leave the URL blank for a static welcome-only bot.
</p>
</div>
<div class="field">
<label>API Base URL</label>
<input type="text" name="api_base" placeholder="https://api.x.ai/v1 (Ollama: http://localhost:11434/v1)">
</div>
<div class="field">
<label>API Key</label>
<input type="password" name="api_key" placeholder="xai-… (any value for Ollama)">
</div>
<div class="field">
<label>Model</label>
<input type="text" name="model" placeholder="grok-2 (Ollama: llama3.2)">
</div>
<div class="field">
<label>System Prompt</label>
<textarea name="system_prompt" rows="3" placeholder="You are a helpful customer-support assistant…"></textarea>
</div>
</div>
<div id="deadmans-fields" style="display:none;">
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
<p class="muted" style="margin-bottom:12px;">
Fires a message to recipients if no check-in arrives in time. Check in by messaging the bot.
</p>
</div>
<div class="field">
<label>Check-in window (hours)</label>
<input type="number" name="checkin_hours" min="0.1" step="0.1" value="24">
</div>
<div class="field">
<label>Trigger message</label>
<textarea name="dms_message" rows="2" placeholder="If you receive this, I haven't checked in…"></textarea>
</div>
<div class="field">
<label>Recipients <span class="muted" style="font-weight:400;">(comma-separated names; blank = all contacts)</span></label>
<input type="text" name="recipients" placeholder="Alice, Bob">
</div>
<div class="field">
<label>Owner <span class="muted" style="font-weight:400;">(only this contact's messages count as check-in; blank = anyone)</span></label>
<input type="text" name="owner" placeholder="Alice">
</div>
</div>
<div id="directory-fields" style="display:none;">
<div style="border-top:1px solid var(--border);margin:4px 0 14px;padding-top:14px;">
<p class="muted" style="margin-bottom:12px;">
Group owners register by adding this bot to their group. Listings stay pending until a super-user approves.
</p>
</div>
<div class="field">
<label>Super-users <span class="muted" style="font-weight:400;">(comma-separated contact names who can /approve)</span></label>
<input type="text" name="superusers" placeholder="Alice, Bob">
</div>
</div>
{% endif %} {% endif %}
<div class="flex gap-8 mt-16" style="justify-content:flex-end;"> <div class="flex gap-8 mt-16" style="justify-content:flex-end;">
<button type="button" class="btn btn-ghost" <button type="button" class="btn btn-ghost"
@@ -131,12 +203,43 @@ function updateStatus(id, event) {
} catch(e) {} } catch(e) {}
} }
let avatarDataUri = '';
function openCreate() { function openCreate() {
document.getElementById('create-form').reset(); document.getElementById('create-form').reset();
avatarDataUri = '';
const prev = document.getElementById('avatar-preview');
prev.style.display = 'none'; prev.src = '';
{% if tab == 'bots' %}onTypeChange();{% endif %} {% if tab == 'bots' %}onTypeChange();{% endif %}
document.getElementById('create-dialog').showModal(); document.getElementById('create-dialog').showModal();
} }
// Read an image file, downscale it to a small square data URI (avatars are sent
// over the wire to every contact, so keep them tiny). Stores result in avatarDataUri.
function onAvatarChange(input) {
const file = input.files && input.files[0];
if (!file) { avatarDataUri = ''; return; }
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size; canvas.height = size;
const ctx = canvas.getContext('2d');
// center-crop to square
const m = Math.min(img.width, img.height);
const sx = (img.width - m) / 2, sy = (img.height - m) / 2;
ctx.drawImage(img, sx, sy, m, m, 0, 0, size, size);
avatarDataUri = canvas.toDataURL('image/jpeg', 0.85);
const prev = document.getElementById('avatar-preview');
prev.src = avatarDataUri; prev.style.display = 'block';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function copyAddr(ev, btn, addr) { function copyAddr(ev, btn, addr) {
ev.stopPropagation(); ev.stopPropagation();
navigator.clipboard.writeText(addr).then(() => { navigator.clipboard.writeText(addr).then(() => {
@@ -148,8 +251,10 @@ function copyAddr(ev, btn, addr) {
{% if tab == 'bots' %} {% if tab == 'bots' %}
function onTypeChange() { function onTypeChange() {
const val = document.getElementById('type-select').value; const val = document.getElementById('type-select').value;
const hide = ['echo'].includes(val); // echo has no welcome msg document.getElementById('welcome-field').style.display = (val === 'echo') ? 'none' : '';
document.getElementById('welcome-field').style.display = hide ? 'none' : ''; document.getElementById('support-fields').style.display = (val === 'support') ? 'block' : 'none';
document.getElementById('deadmans-fields').style.display = (val === 'deadmans') ? 'block' : 'none';
document.getElementById('directory-fields').style.display = (val === 'directory') ? 'block' : 'none';
} }
{% endif %} {% endif %}
@@ -164,7 +269,35 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
const config = {}; const config = {};
const welcome = fd.get('welcome_message'); const welcome = fd.get('welcome_message');
if (welcome) config.welcome_message = welcome; if (welcome) config.welcome_message = welcome;
if (botType === 'support') {
const apiBase = (fd.get('api_base') || '').trim();
if (apiBase) config.api_base = apiBase;
const apiKey = (fd.get('api_key') || '').trim();
if (apiKey) config.api_key = apiKey;
const model = (fd.get('model') || '').trim();
if (model) config.model = model;
const sysPrompt = (fd.get('system_prompt') || '').trim();
if (sysPrompt) config.system_prompt = sysPrompt;
}
if (botType === 'deadmans') {
const hrs = parseFloat(fd.get('checkin_hours'));
if (!isNaN(hrs) && hrs > 0) config.checkin_hours = hrs;
const dmsMsg = (fd.get('dms_message') || '').trim();
if (dmsMsg) config.message = dmsMsg;
const recips = (fd.get('recipients') || '').split(',').map(s => s.trim()).filter(Boolean);
if (recips.length) config.recipients = recips;
const owner = (fd.get('owner') || '').trim();
if (owner) config.owner = owner;
}
if (botType === 'directory') {
const su = (fd.get('superusers') || '').split(',').map(s => s.trim()).filter(Boolean);
if (su.length) config.superusers = su;
}
{% endif %} {% endif %}
// Shared profile fields (users and bots)
const bio = (fd.get('bio') || '').trim();
if (bio) config.bio = bio;
if (avatarDataUri) config.avatar = avatarDataUri;
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
const resp = await fetch('/api/profiles', { const resp = await fetch('/api/profiles', {
method: 'POST', method: 'POST',

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Network — SimpleX Manager{% endblock %}
{% block head %}
<style>
.srv-table td { padding: 6px 12px; }
.srv-host { font-family: monospace; font-size: 12px; }
.srv-off { opacity: 0.5; }
.op-sub { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px;
color: var(--muted); margin: 14px 0 6px; }
.net-table td:first-child { color: var(--muted); width: 45%; }
</style>
{% endblock %}
{% block content %}
<h1>Network</h1>
{% if not detail.profile_name %}
<div class="card" style="text-align:center;padding:48px;color:var(--muted);">
No running profile. Start a profile to view SMP/XFTP servers and network status.
</div>
{% else %}
<p class="muted" style="margin-bottom:16px;">
Servers and network config for <strong>{{ detail.profile_name }}</strong> (SimpleX presets are shared across profiles).
</p>
{% if detail.network %}
<div class="card">
<h2>Network configuration</h2>
<table class="net-table">
<tr><td>SMP proxy mode</td><td>{{ detail.network.smpProxyMode | default('—', true) }}</td></tr>
<tr><td>SMP proxy fallback</td><td>{{ detail.network.smpProxyFallback | default('—', true) }}</td></tr>
<tr><td>Host mode</td><td>{{ detail.network.hostMode | default('—', true) }}</td></tr>
<tr><td>Required host mode</td><td>{{ detail.network.requiredHostMode | default('—', true) }}</td></tr>
<tr><td>Session mode</td><td>{{ detail.network.sessionMode | default('—', true) }}</td></tr>
<tr><td>TCP connect timeout</td><td>{{ detail.network.tcpConnectTimeout | default('—', true) }}</td></tr>
</table>
</div>
{% endif %}
{% for op in detail.operators %}
<div class="card">
<div class="flex-between" style="margin-bottom:6px;">
<h2 style="margin:0;">{{ op.name }}</h2>
<span class="badge {% if op.enabled %}badge-green{% else %}badge-red{% endif %}">
{{ 'enabled' if op.enabled else 'disabled' }}
</span>
</div>
<div class="op-sub">SMP — messaging ({{ op.smp | length }})</div>
{% if op.smp %}
<table class="srv-table">
{% for s in op.smp %}
<tr class="{% if not s.enabled or s.deleted %}srv-off{% endif %}">
<td class="srv-host">{{ s.host }}</td>
<td style="text-align:right;">
{% if s.preset %}<span class="tag">preset</span>{% endif %}
{% if s.deleted %}<span class="badge badge-red">deleted</span>
{% elif s.enabled %}<span class="badge badge-green">on</span>
{% else %}<span class="badge badge-red">off</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="muted">None.</p>{% endif %}
<div class="op-sub">XFTP — files ({{ op.xftp | length }})</div>
{% if op.xftp %}
<table class="srv-table">
{% for s in op.xftp %}
<tr class="{% if not s.enabled or s.deleted %}srv-off{% endif %}">
<td class="srv-host">{{ s.host }}</td>
<td style="text-align:right;">
{% if s.preset %}<span class="tag">preset</span>{% endif %}
{% if s.deleted %}<span class="badge badge-red">deleted</span>
{% elif s.enabled %}<span class="badge badge-green">on</span>
{% else %}<span class="badge badge-red">off</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}<p class="muted">None.</p>{% endif %}
</div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Notifications — SimpleX Manager{% endblock %}
{% block head %}
<style>
.notif-item { display: block; padding: 14px 18px; border-bottom: 1px solid var(--border);
text-decoration: none; color: var(--text); border-left: 3px solid transparent; }
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: var(--bg); }
.notif-item.unread { border-left-color: var(--accent); }
.notif-text { margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 540px; }
</style>
{% endblock %}
{% block content %}
<div class="flex-between" style="margin-bottom: 24px;">
<h1 style="margin:0;">Notifications</h1>
{% if items %}
<button class="btn btn-ghost" onclick="markAllRead()">Mark all read</button>
{% endif %}
</div>
{% if items %}
<div class="card" style="padding:0;">
{% for n in items %}
<a class="notif-item {% if not n.read %}unread{% endif %}"
href="/profile/{{ n.profile_id }}/chat/{{ n.chat_type }}/{{ n.chat_id }}">
<div class="flex-between">
<div style="min-width:0;">
<div><strong>{{ n.sender or 'Someone' }}</strong> <span class="muted">→ {{ n.profile_name }}</span></div>
<div class="muted notif-text">{{ n.text }}</div>
</div>
<span class="muted notif-time" data-ts="{{ n.ts }}" style="flex-shrink:0;margin-left:12px;"></span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center;padding:48px;color:var(--muted);">
No notifications yet. Incoming messages across all accounts will appear here.
</div>
{% endif %}
<script>
function _ntoken(){ return document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; }
async function markAllRead() {
await fetch('/api/notifications/read', { method: 'POST', headers: { 'X-Token': _ntoken() } });
location.reload();
}
// Localize timestamps
document.querySelectorAll('.notif-time').forEach(el => {
const d = new Date(el.dataset.ts);
if (!isNaN(d)) el.textContent = d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
});
// Mark read shortly after viewing so the badge clears (keeps this view's highlights)
setTimeout(() => {
fetch('/api/notifications/read', { method: 'POST', headers: { 'X-Token': _ntoken() } });
}, 1200);
</script>
{% endblock %}

View File

@@ -6,8 +6,6 @@
.qr-wrap { text-align: center; padding: 16px; } .qr-wrap { text-align: center; padding: 16px; }
.qr-wrap canvas { border-radius: 8px; } .qr-wrap canvas { border-radius: 8px; }
.row-action { opacity: 0; transition: opacity 0.15s; }
tr:hover .row-action { opacity: 1; }
.msg-btn { .msg-btn {
padding: 3px 10px; font-size: 12px; border-radius: 6px; padding: 3px 10px; font-size: 12px; border-radius: 6px;
@@ -17,6 +15,8 @@
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.msg-btn:hover { background: var(--accent); color: var(--btn-light-text); } .msg-btn:hover { background: var(--accent); color: var(--btn-light-text); }
.msg-btn-danger { color: var(--red); border-color: var(--red); }
.msg-btn-danger:hover { background: var(--red); color: #fff; }
.addr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .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; .addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
@@ -56,6 +56,34 @@
<div class="grid-2"> <div class="grid-2">
<!-- Left column --> <!-- Left column -->
<div> <div>
<!-- Profile -->
<div class="card">
<div class="flex-between" style="margin-bottom:14px;">
<h2 style="margin:0;">Profile</h2>
<button class="btn btn-ghost" style="padding:6px 14px;font-size:13px;" onclick="openEdit()">Edit</button>
</div>
<div class="flex gap-8" style="align-items:flex-start;">
{% if profile.config.avatar %}
<img src="{{ profile.config.avatar }}" alt="avatar"
style="width:64px;height:64px;border-radius:50%;object-fit:cover;border:1px solid var(--border);flex-shrink:0;">
{% else %}
<div style="width:64px;height:64px;border-radius:50%;background:var(--border);flex-shrink:0;
display:flex;align-items:center;justify-content:center;font-size:26px;font-weight:700;color:var(--muted);">
{{ profile.name[0] | upper }}
</div>
{% endif %}
<div style="min-width:0;">
<div style="font-weight:700;font-size:16px;">{{ profile.name }}</div>
{% if profile.config.full_name %}<div class="muted">{{ profile.config.full_name }}</div>{% endif %}
{% if profile.config.bio %}
<div style="margin-top:6px;font-size:14px;white-space:pre-wrap;">{{ profile.config.bio }}</div>
{% else %}
<div class="muted" style="margin-top:6px;">No bio set.</div>
{% endif %}
</div>
</div>
</div>
<!-- Address / QR --> <!-- Address / QR -->
<div class="card"> <div class="card">
<h2>Address</h2> <h2>Address</h2>
@@ -78,15 +106,29 @@
{% endif %} {% endif %}
</div> </div>
{% if profile.bot_type == 'directory' %}
{% set safe = profile.name | lower | replace(' ', '_') %}
<!-- Directory website -->
<div class="card">
<h2>Directory website</h2>
<p class="muted" style="margin-bottom:12px;">Auto-generated listing page for this directory bot.</p>
<div class="addr-row">
<button class="btn btn-ghost copy-btn" title="Copy URL"
onclick="copyAddr(this, location.origin + '/directory/{{ safe }}/index.html')">📋</button>
<a class="addr-link" href="/directory/{{ safe }}/index.html" target="_blank" rel="noopener">/directory/{{ safe }}/index.html</a>
</div>
</div>
{% endif %}
<!-- Config --> <!-- Config -->
<div class="card"> <div class="card">
<h2>Config</h2> <h2>Config</h2>
<table> <table>
<tr><th>Key</th><th>Value</th></tr> <tr><th>Key</th><th>Value</th></tr>
{% for k, v in profile.config.items() %} {% for k, v in profile.config.items() if k not in ['avatar', 'bio', 'full_name'] %}
<tr><td>{{ k }}</td><td>{{ v }}</td></tr> <tr><td>{{ k }}</td><td>{% if k == 'api_key' %}•••••••• (set){% else %}{{ v }}{% endif %}</td></tr>
{% else %} {% else %}
<tr><td colspan="2" class="muted">No config set.</td></tr> <tr><td colspan="2" class="muted">No extra config set.</td></tr>
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
@@ -104,8 +146,14 @@
<tr> <tr>
<td><strong>{{ c.localDisplayName }}</strong></td> <td><strong>{{ c.localDisplayName }}</strong></td>
<td> <td>
<a class="msg-btn row-action" style="text-decoration:none;" <div class="flex gap-8" style="justify-content:flex-end;">
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a> <a class="msg-btn" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
<button class="msg-btn" title="Clear conversation"
onclick="clearChat('direct', {{ c.contactId }}, '{{ c.localDisplayName | e }}')">🧹 Clear</button>
<button class="msg-btn msg-btn-danger" title="Delete contact"
onclick="deleteContact({{ c.contactId }}, '{{ c.localDisplayName | e }}')">🗑 Delete</button>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -122,17 +170,31 @@
{% set name = g.groupProfile.displayName %} {% set name = g.groupProfile.displayName %}
{% set gid = g.groupId %} {% set gid = g.groupId %}
{% set mcnt = g.groupSummary.currentMembers %} {% set mcnt = g.groupSummary.currentMembers %}
{% set invited = (g.membership.memberStatus if g.membership else '') == 'invited' %}
{% set is_owner = (g.membership.memberRole if g.membership else '') == 'owner' %}
<tr> <tr>
<td>{{ name }}</td> <td>{{ name }}</td>
<td> <td>
{% if invited %}
<span class="tag" title="You were invited but haven't joined yet">⏳ invited</span>
{% else %}
<button class="msg-btn" style="border:none;padding:0;background:none;color:var(--accent);font-weight:600;font-size:13px;cursor:pointer;" <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> onclick="loadMembers({{ gid }}, '{{ name | e }}')">{{ mcnt }}</button>
{% endif %}
</td> </td>
<td> <td>
<div class="flex gap-8"> <div class="flex gap-8">
<a class="msg-btn row-action" style="text-decoration:none;" {% if invited %}
<button class="msg-btn" onclick="joinGroup({{ gid }}, this)">Join</button>
{% else %}
<a class="msg-btn" style="text-decoration:none;"
href="/profile/{{ profile.id }}/chat/group/{{ gid }}">💬 {{ 'Broadcast' if g.is_channel else 'Chat' }}</a> 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> <button class="msg-btn" onclick="getGroupLink({{ gid }}, this)">Link</button>
<button class="msg-btn msg-btn-danger" onclick="leaveGroup({{ gid }}, '{{ name | e }}', this)">Leave</button>
{% if is_owner %}
<button class="msg-btn msg-btn-danger" onclick="deleteGroup({{ gid }}, '{{ name | e }}', this)">Delete</button>
{% endif %}
{% endif %}
</div> </div>
</td> </td>
</tr> </tr>
@@ -227,6 +289,35 @@
</div> </div>
</dialog> </dialog>
<!-- Edit profile dialog -->
<dialog id="edit-dialog">
<h2 style="margin-bottom:16px;">Edit Profile</h2>
<div class="field">
<label>Full Name <span class="muted" style="font-weight:400;">(optional)</span></label>
<input type="text" id="edit-fullname">
</div>
<div class="field">
<label>Bio / Description <span class="muted" style="font-weight:400;">(optional)</span></label>
<textarea id="edit-bio" rows="2"></textarea>
</div>
<div class="field">
<label>Avatar</label>
<div class="flex gap-8">
<img id="edit-avatar-preview" alt="" style="display:none;width:48px;height:48px;border-radius:50%;object-fit:cover;border:1px solid var(--border);">
<input type="file" accept="image/*" onchange="onEditAvatar(this)" style="flex:1;">
</div>
<button type="button" class="btn btn-ghost" style="margin-top:6px;font-size:12px;padding:4px 10px;"
onclick="removeEditAvatar()">Remove avatar</button>
</div>
<div class="flex-between mt-16">
<span id="edit-result" class="muted" style="font-size:13px;"></span>
<div class="flex gap-8">
<button class="btn btn-ghost" onclick="document.getElementById('edit-dialog').close()">Cancel</button>
<button class="btn btn-primary" onclick="saveProfile()">Save</button>
</div>
</div>
</dialog>
<!-- Send message dialog --> <!-- Send message dialog -->
<dialog id="msg-dialog"> <dialog id="msg-dialog">
<h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2> <h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2>
@@ -285,6 +376,88 @@ function copyAddr(btn, addr) {
}); });
} }
// ── Contact actions ────────────────────────────────────────────────────────
function _ctoken() { return document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; }
async function clearChat(type, id, name) {
if (!confirm('Clear the conversation with ' + name + '? Messages are removed; the contact stays.')) return;
const r = await fetch(`/api/profiles/{{ profile.id }}/chat/${type}/${id}/clear`, {
method: 'POST', headers: { 'X-Token': _ctoken() },
});
const d = await r.json();
if (d.ok) { alert('Conversation cleared.'); }
else { alert('Failed: ' + (d.detail || 'unknown')); }
}
async function deleteContact(id, name) {
if (!confirm('Delete contact ' + name + '? This removes them and your conversation.')) return;
const r = await fetch(`/api/profiles/{{ profile.id }}/contacts/${id}`, {
method: 'DELETE', headers: { 'X-Token': _ctoken() },
});
const d = await r.json();
if (d.ok) { location.reload(); }
else { alert('Failed: ' + (d.detail || 'unknown')); }
}
// ── Edit profile ───────────────────────────────────────────────────────────
// editAvatar: null = unchanged, '' = remove, dataURI = replace
let editAvatar = null;
function openEdit() {
editAvatar = null;
document.getElementById('edit-fullname').value = {{ (profile.config.full_name or '') | tojson }};
document.getElementById('edit-bio').value = {{ (profile.config.bio or '') | tojson }};
const prev = document.getElementById('edit-avatar-preview');
const cur = {{ (profile.config.avatar or '') | tojson }};
if (cur) { prev.src = cur; prev.style.display = 'block'; } else { prev.style.display = 'none'; prev.src = ''; }
document.getElementById('edit-result').textContent = '';
document.getElementById('edit-dialog').showModal();
}
function onEditAvatar(input) {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const size = 256, c = document.createElement('canvas');
c.width = size; c.height = size;
const ctx = c.getContext('2d');
const m = Math.min(img.width, img.height);
ctx.drawImage(img, (img.width - m) / 2, (img.height - m) / 2, m, m, 0, 0, size, size);
editAvatar = c.toDataURL('image/jpeg', 0.85);
const prev = document.getElementById('edit-avatar-preview');
prev.src = editAvatar; prev.style.display = 'block';
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function removeEditAvatar() {
editAvatar = '';
const prev = document.getElementById('edit-avatar-preview');
prev.style.display = 'none'; prev.src = '';
}
async function saveProfile() {
const body = {
full_name: document.getElementById('edit-fullname').value,
bio: document.getElementById('edit-bio').value,
};
if (editAvatar !== null) body.avatar = editAvatar; // only send if changed
document.getElementById('edit-result').textContent = 'Saving…';
const resp = await fetch('/api/profiles/{{ profile.id }}/profile', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { document.getElementById('edit-result').textContent = '✗ ' + (data.detail || 'Failed'); }
}
// ── Groups & Channels ────────────────────────────────────────────────────── // ── Groups & Channels ──────────────────────────────────────────────────────
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
let _createKind = 'group'; let _createKind = 'group';
@@ -367,6 +540,38 @@ async function loadMembers(groupId, groupName) {
</table>`; </table>`;
} }
async function joinGroup(groupId, btn) {
btn.textContent = 'Joining…'; btn.disabled = true;
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/join`, {
method: 'POST', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.textContent = 'Join'; btn.disabled = false; alert('Failed to join: ' + (data.detail || 'unknown')); }
}
async function deleteGroup(groupId, name, btn) {
if (!confirm('Delete "' + name + '" for everyone? This removes the group/channel and notifies members.')) return;
btn.disabled = true; btn.textContent = 'Deleting…';
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}`, {
method: 'DELETE', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = 'Delete'; alert('Failed to delete: ' + (data.detail || 'unknown')); }
}
async function leaveGroup(groupId, name, btn) {
if (!confirm('Leave "' + name + '"? You will stop receiving its messages.')) return;
btn.disabled = true; btn.textContent = 'Leaving…';
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/leave`, {
method: 'POST', headers: { 'X-Token': _token() },
});
const data = await resp.json();
if (data.ok) { location.reload(); }
else { btn.disabled = false; btn.textContent = 'Leave'; alert('Failed to leave: ' + (data.detail || 'unknown')); }
}
async function getGroupLink(groupId, btn) { async function getGroupLink(groupId, btn) {
const orig = btn.textContent; const orig = btn.textContent;
btn.textContent = '…'; btn.textContent = '…';

View File

@@ -133,6 +133,21 @@
</div> </div>
</div> </div>
<div class="card settings-section">
<h2>Network</h2>
{% if network %}
<table>
<tr><td style="color:var(--muted);width:45%;">SMP proxy mode</td><td>{{ network.smpProxyMode | default('—', true) }}</td></tr>
<tr><td style="color:var(--muted);">SMP proxy fallback</td><td>{{ network.smpProxyFallback | default('—', true) }}</td></tr>
<tr><td style="color:var(--muted);">Host mode</td><td>{{ network.hostMode | default('—', true) }}</td></tr>
<tr><td style="color:var(--muted);">Session mode</td><td>{{ network.sessionMode | default('—', true) }}</td></tr>
</table>
<p class="muted" style="margin-top:12px;">Read-only here. <a href="/network" style="color:var(--accent);">View full server list →</a></p>
{% else %}
<p class="muted">Start a profile to view network configuration.</p>
{% endif %}
</div>
<script> <script>
function currentTheme() { function currentTheme() {
return localStorage.getItem('theme') || return localStorage.getItem('theme') ||