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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
114
manager/directory.py
Normal 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)
|
||||||
132
manager/main.py
132
manager/main.py
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
try:
|
|
||||||
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text)
|
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
|
||||||
log.error("send_to_chat error: %s", e)
|
|
||||||
return False
|
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:
|
||||||
|
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:
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & 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> 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
86
manager/templates/network.html
Normal file
86
manager/templates/network.html
Normal 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 %}
|
||||||
63
manager/templates/notifications.html
Normal file
63
manager/templates/notifications.html
Normal 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 %}
|
||||||
@@ -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;">
|
||||||
|
<a class="msg-btn" style="text-decoration:none;"
|
||||||
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
|
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 = '…';
|
||||||
|
|||||||
@@ -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') ||
|
||||||
|
|||||||
Reference in New Issue
Block a user