diff --git a/.gitignore b/.gitignore index c32c59d..8d05020 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ manager/libs/ manager/data/explore/ # Local Claude session resume helper (machine-specific) 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/ diff --git a/manager/db.py b/manager/db.py index 05267bd..074d7ed 100644 --- a/manager/db.py +++ b/manager/db.py @@ -61,6 +61,13 @@ def update_address(profile_id: int, address: str) -> None: 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: with get_conn() as conn: conn.execute("DELETE FROM profiles WHERE id=?", (profile_id,)) diff --git a/manager/directory.py b/manager/directory.py new file mode 100644 index 0000000..181f781 --- /dev/null +++ b/manager/directory.py @@ -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//data/listing.json) in the format index.html expects. + +State is a per-bot JSON file so it survives restarts: data/bots/_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//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) diff --git a/manager/main.py b/manager/main.py index 125c578..71a4f0d 100644 --- a/manager/main.py +++ b/manager/main.py @@ -37,6 +37,10 @@ async def lifespan(app: FastAPI): app = FastAPI(title="SimpleX Manager", lifespan=lifespan) 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: @@ -122,7 +126,52 @@ async def bots_page(request: Request): async def settings_page(request: Request): if redir := _redirect_if_unauth(request): 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) @@ -134,6 +183,8 @@ async def profile_page(request: Request, profile_id: int): raise HTTPException(404, "Profile not found") profile["config"] = json.loads(profile.get("config") or "{}") 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) contacts = bot.contacts 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() if not text: 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}) @@ -238,9 +313,32 @@ async def create_profile(request: Request): profile = db.create_profile(name, bot_type, config) except Exception as 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) +@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}") async def delete_profile(request: Request, profile_id: int): _require_auth(request) @@ -318,6 +416,36 @@ async def create_group(request: Request, profile_id: int): 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") async def group_members(request: Request, profile_id: int, group_id: int): _require_auth(request) diff --git a/manager/profiles.py b/manager/profiles.py index a1cd981..5f03e20 100644 --- a/manager/profiles.py +++ b/manager/profiles.py @@ -3,11 +3,112 @@ import asyncio import json import logging +import urllib.request from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path from typing import Any log = logging.getLogger(__name__) +# Directory bot websites live in /web//, 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): # g["groupId"], g["groupProfile"]["displayName"], @@ -47,6 +148,8 @@ class RunningBot: groups: list[dict] = field(default_factory=list) log_lines: list[str] = field(default_factory=list) 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 @@ -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: - """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) 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: - 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 + user = await b.chat.api_get_active_user() + if not user: + return + uid = user["userId"] + 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: @@ -173,6 +295,34 @@ async def get_chat_history( 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( profile_id: int, name: str, @@ -197,13 +347,13 @@ async def _run_bot( # libsimplex /_start requires an active user to exist first user = await chat.api_get_active_user() if not user: - user = await chat.api_create_active_user( - {"displayName": name, "fullName": ""} - ) + user = await chat.api_create_active_user(_profile_dict(name, config)) await chat.start_chat() 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) if existing: # api_get_user_address returns UserContactLink; link is nested under connLinkContact @@ -244,9 +394,35 @@ async def _run_bot( 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 while True: 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: continue @@ -276,8 +452,11 @@ async def _run_bot( items = evt.get("chatItems", []) for item in items: ci = item.get("chatItem", {}) - direction = ci.get("meta", {}).get("itemStatus", {}).get("type", "") - if direction != "sndSent": + # Robust incoming-vs-outgoing: chatDir ".../Rcv" = received, + # ".../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", {}) mc = content.get("msgContent", {}) text = mc.get("text", "") @@ -285,12 +464,58 @@ async def _run_bot( _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: try: await chat.api_send_text_reply(item, f"Echo: {text}") except Exception: 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": publishers = config.get("publishers", []) 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:] +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 , /reject .") + 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} ") + 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: """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]: - """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) if not b or not b.chat: raise RuntimeError("Profile is not running") members = await b.chat.api_list_members(gid) - return [ + result = [ {"name": m["localDisplayName"], "role": m["memberRole"], "status": m["memberStatus"]} 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: diff --git a/manager/start.sh b/manager/start.sh index 13fd740..3634601 100755 --- a/manager/start.sh +++ b/manager/start.sh @@ -23,4 +23,4 @@ echo " URL: http://0.0.0.0:8000" echo " Token: $MANAGER_TOKEN" echo "" -exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 +exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload diff --git a/manager/templates/base.html b/manager/templates/base.html index ed658da..6c81365 100644 --- a/manager/templates/base.html +++ b/manager/templates/base.html @@ -107,17 +107,39 @@ .side-nav { display: flex; flex-direction: column; padding: 8px 0; } .side-nav a { + position: relative; 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; } + .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.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); } + + .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 { display: flex; align-items: center; gap: 12px; width: 100%; padding: 11px 18px; background: none; border: none; cursor: pointer; @@ -246,9 +268,17 @@ 👤Users 🤖Bots 📁File upload + 🔔Notifications ⚙️Settings
+ +
Network ›
+
–/– running
+
📡 –
+
+
@@ -285,6 +315,41 @@ const ico = document.getElementById('collapse-ico'); 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); diff --git a/manager/templates/chat.html b/manager/templates/chat.html index bc7d6b6..0c652d5 100644 --- a/manager/templates/chat.html +++ b/manager/templates/chat.html @@ -140,7 +140,7 @@ async function sendMsg() { const data = await resp.json(); if (!data.ok) { input.value = text; // restore on failure - alert('Failed to send'); + alert('Failed to send: ' + (data.error || data.detail || 'unknown error')); return; } setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly diff --git a/manager/templates/list.html b/manager/templates/list.html index 1f1f8a4..c4ea07e 100644 --- a/manager/templates/list.html +++ b/manager/templates/list.html @@ -49,6 +49,10 @@ onclick="location.href='/profile/{{ p.id }}'">
+ {% if p.config.avatar %} + + {% endif %} {{ p.name }} {{ p.bot_type }} Name
+
+ + +
+
+ +
+ + +
+
{% if tab == 'bots' %}
@@ -111,6 +126,63 @@
+ + + {% endif %}
+ {% endif %} +
+ +{% if items %} + +{% else %} +
+ No notifications yet. Incoming messages across all accounts will appear here. +
+{% endif %} + + +{% endblock %} diff --git a/manager/templates/profile.html b/manager/templates/profile.html index 84a9c41..6a63e09 100644 --- a/manager/templates/profile.html +++ b/manager/templates/profile.html @@ -6,8 +6,6 @@ .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; @@ -17,6 +15,8 @@ transition: background 0.15s, color 0.15s; } .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-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px; @@ -56,6 +56,34 @@
+ +
+
+

Profile

+ +
+
+ {% if profile.config.avatar %} + avatar + {% else %} +
+ {{ profile.name[0] | upper }} +
+ {% endif %} +
+
{{ profile.name }}
+ {% if profile.config.full_name %}
{{ profile.config.full_name }}
{% endif %} + {% if profile.config.bio %} +
{{ profile.config.bio }}
+ {% else %} +
No bio set.
+ {% endif %} +
+
+
+

Address

@@ -78,15 +106,29 @@ {% endif %}
+ {% if profile.bot_type == 'directory' %} + {% set safe = profile.name | lower | replace(' ', '_') %} + +
+

Directory website

+

Auto-generated listing page for this directory bot.

+ +
+ {% endif %} +

Config

- {% for k, v in profile.config.items() %} - + {% for k, v in profile.config.items() if k not in ['avatar', 'bio', 'full_name'] %} + {% else %} - + {% endfor %}
KeyValue
{{ k }}{{ v }}
{{ k }}{% if k == 'api_key' %}•••••••• (set){% else %}{{ v }}{% endif %}
No config set.
No extra config set.
@@ -104,8 +146,14 @@ {{ c.localDisplayName }} - 💬 Chat +
+ 💬 Chat + + +
{% endfor %} @@ -122,17 +170,31 @@ {% set name = g.groupProfile.displayName %} {% set gid = g.groupId %} {% 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' %} {{ name }} + {% if invited %} + ⏳ invited + {% else %} + {% endif %}
- Join + {% else %} + 💬 {{ 'Broadcast' if g.is_channel else 'Chat' }} - + + + {% if is_owner %} + + {% endif %} + {% endif %}
@@ -227,6 +289,35 @@
+ + +

Edit Profile

+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+

Message

@@ -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 ────────────────────────────────────────────────────── const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; let _createKind = 'group'; @@ -367,6 +540,38 @@ async function loadMembers(groupId, groupName) { `; } +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) { const orig = btn.textContent; btn.textContent = '…'; diff --git a/manager/templates/settings.html b/manager/templates/settings.html index 9d44fa9..2f9070c 100644 --- a/manager/templates/settings.html +++ b/manager/templates/settings.html @@ -133,6 +133,21 @@
+
+

Network

+ {% if network %} + + + + + +
SMP proxy mode{{ network.smpProxyMode | default('—', true) }}
SMP proxy fallback{{ network.smpProxyFallback | default('—', true) }}
Host mode{{ network.hostMode | default('—', true) }}
Session mode{{ network.sessionMode | default('—', true) }}
+

Read-only here. View full server list →

+ {% else %} +

Start a profile to view network configuration.

+ {% endif %} +
+