Compare commits
33 Commits
ecce417f6d
...
7c712c9ee3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c712c9ee3 | ||
|
|
432e4a5e83 | ||
|
|
895cc6ddfa | ||
|
|
87029f6d2a | ||
|
|
28a4c22ef3 | ||
|
|
332b4a1801 | ||
|
|
3456ed9411 | ||
|
|
7cda767408 | ||
|
|
a43061e096 | ||
|
|
1bd0bd9c7b | ||
|
|
908d16bfc3 | ||
|
|
12d21e6de5 | ||
|
|
9c083dc6d9 | ||
|
|
7f12820eb3 | ||
|
|
62489b84b7 | ||
|
|
4ed2f9ba14 | ||
|
|
1881b74d92 | ||
|
|
ea5efb06d8 | ||
|
|
d3a5cb18e4 | ||
|
|
aaf3c23a18 | ||
|
|
3f0338041c | ||
|
|
37925edcdf | ||
|
|
22b5ee7203 | ||
|
|
5a5134f9b2 | ||
|
|
3f9683e07f | ||
|
|
34469455a4 | ||
|
|
2e298e1438 | ||
|
|
964d5e1efa | ||
|
|
270766b99b | ||
|
|
2194aa0f82 | ||
|
|
609e91c6de | ||
|
|
6232c1589d | ||
|
|
c1bb9cb955 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,3 +17,8 @@ manager/libs/
|
||||
manager/data/explore/
|
||||
# Local Claude session resume helper (machine-specific)
|
||||
manager/ai.sh
|
||||
# Runtime bot state (databases + directory registries)
|
||||
manager/data/bots/
|
||||
# Generated directory-bot websites (web/index.html is the master template)
|
||||
/web/*/
|
||||
!/web/data/
|
||||
|
||||
144
manager/broadcast_test.py
Normal file
144
manager/broadcast_test.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""End-to-end test of the broadcast bot (Pattern 3, in-process FFI).
|
||||
|
||||
Runs the real bot via profiles.start_bot, connects a publisher ("pub") and a
|
||||
non-publisher ("sub") to it, then checks:
|
||||
- a publisher's message is broadcast to all contacts (sub receives it)
|
||||
- a non-publisher's message gets the prohibited reply and is deleted
|
||||
|
||||
Uses three libsimplex controllers in one process (bot + pub + sub) — exactly the
|
||||
multi-controller model the manager relies on. Needs network (SMP).
|
||||
|
||||
Run: .venv/bin/python broadcast_test.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import profiles as pm # noqa: E402
|
||||
from simplex_chat import ChatApi, SqliteDb # noqa: E402
|
||||
|
||||
DATA = Path("data")
|
||||
BOT_PREFIX = str(DATA / "bctest_bot")
|
||||
PUB_PREFIX = str(DATA / "bctest_pub")
|
||||
SUB_PREFIX = str(DATA / "bctest_sub")
|
||||
BOT_PID = 99001
|
||||
|
||||
|
||||
def cleanup():
|
||||
for pat in ("bctest_bot_*", "bctest_pub_*", "bctest_sub_*"):
|
||||
for p in DATA.glob(pat):
|
||||
p.unlink()
|
||||
|
||||
|
||||
async def make_account(prefix: str, display: str) -> ChatApi:
|
||||
chat = await ChatApi.init(SqliteDb(file_prefix=prefix))
|
||||
user = await chat.api_get_active_user()
|
||||
if not user:
|
||||
await chat.api_create_active_user({"displayName": display, "fullName": ""})
|
||||
await chat.start_chat()
|
||||
return chat
|
||||
|
||||
|
||||
async def wait_until(fn, timeout=120, every=1):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
v = await fn()
|
||||
if v:
|
||||
return v
|
||||
await asyncio.sleep(every)
|
||||
return None
|
||||
|
||||
|
||||
async def first_contact_id(chat: ChatApi) -> int | None:
|
||||
u = await chat.api_get_active_user()
|
||||
cs = await chat.api_list_contacts(u["userId"])
|
||||
return cs[0]["contactId"] if cs else None
|
||||
|
||||
|
||||
async def incoming_texts(chat: ChatApi, contact_id: int) -> list[str]:
|
||||
c = await chat.api_get_chat("direct", contact_id, 50)
|
||||
out = []
|
||||
for ci in c.get("chatItems", []):
|
||||
d = ci.get("chatDir", {}).get("type", "")
|
||||
if d.endswith("Rcv"):
|
||||
out.append(ci.get("content", {}).get("msgContent", {}).get("text", ""))
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
cleanup()
|
||||
addr_box = {}
|
||||
|
||||
async def on_address(pid, addr):
|
||||
addr_box["addr"] = addr
|
||||
|
||||
profile = {
|
||||
"id": BOT_PID, "name": "bctestbot", "bot_type": "broadcast",
|
||||
"db_prefix": BOT_PREFIX, "config": json.dumps({"publishers": ["pub"]}),
|
||||
}
|
||||
pub = sub = None
|
||||
ok = True
|
||||
try:
|
||||
await pm.start_bot(profile, on_address)
|
||||
addr = await wait_until(lambda: asyncio.sleep(0, addr_box.get("addr")), timeout=90)
|
||||
print("bot address:", bool(addr))
|
||||
assert addr, "bot never published an address"
|
||||
|
||||
pub = await make_account(PUB_PREFIX, "pub")
|
||||
sub = await make_account(SUB_PREFIX, "sub")
|
||||
await pub.send_chat_cmd(f"/connect {addr}")
|
||||
await sub.send_chat_cmd(f"/connect {addr}")
|
||||
|
||||
pub_cid = await wait_until(lambda: first_contact_id(pub))
|
||||
sub_cid = await wait_until(lambda: first_contact_id(sub))
|
||||
print("pub connected:", bool(pub_cid), "| sub connected:", bool(sub_cid))
|
||||
assert pub_cid and sub_cid, "publisher/subscriber did not connect"
|
||||
# wait until the BOT itself has both contacts, else /feed would miss sub
|
||||
both = await wait_until(
|
||||
lambda: asyncio.sleep(0, len(pm.get_running(BOT_PID).contacts) >= 2), timeout=60
|
||||
)
|
||||
print("bot sees both contacts:", bool(both))
|
||||
|
||||
# 1) publisher broadcasts → sub should receive it
|
||||
await pub.api_send_text_message({"chatType": "direct", "chatId": pub_cid}, "hello all")
|
||||
got_bcast = await wait_until(
|
||||
lambda: _contains(incoming_texts(sub, sub_cid), "hello all"), timeout=60, every=2
|
||||
)
|
||||
print("broadcast delivered to sub:", bool(got_bcast))
|
||||
ok = ok and bool(got_bcast)
|
||||
|
||||
# 2) non-publisher (sub) sends → should get prohibited reply
|
||||
await sub.api_send_text_message({"chatType": "direct", "chatId": sub_cid}, "spam please")
|
||||
got_prohibited = await wait_until(
|
||||
lambda: _contains(incoming_texts(sub, sub_cid), "deleted"), timeout=60, every=2
|
||||
)
|
||||
print("non-publisher got prohibited reply:", bool(got_prohibited))
|
||||
ok = ok and bool(got_prohibited)
|
||||
except AssertionError as e:
|
||||
ok = False
|
||||
print("ASSERT FAIL:", e)
|
||||
finally:
|
||||
await pm.stop_bot(BOT_PID)
|
||||
for c in (pub, sub):
|
||||
if c:
|
||||
try:
|
||||
await c.close()
|
||||
except Exception:
|
||||
pass
|
||||
cleanup()
|
||||
|
||||
print("\nRESULT:", "PASS" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
async def _contains(coro, needle):
|
||||
texts = await coro
|
||||
return any(needle.lower() in (t or "").lower() for t in texts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
100
manager/business_test.py
Normal file
100
manager/business_test.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""End-to-end test of a 'business' profile (Pattern 3, in-process FFI).
|
||||
|
||||
A business profile sets businessAddress=True, so a connecting customer gets their
|
||||
own GROUP chat (business chat) rather than a plain direct contact. This starts a
|
||||
business profile via profiles.start_bot, connects a customer, and asserts the
|
||||
customer ends up in a group (the distinguishing business-address behavior).
|
||||
|
||||
Run: .venv/bin/python business_test.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import profiles as pm # noqa: E402
|
||||
from simplex_chat import ChatApi, SqliteDb # noqa: E402
|
||||
|
||||
DATA = Path("data")
|
||||
BIZ_PREFIX = str(DATA / "biztest_biz")
|
||||
CUST_PREFIX = str(DATA / "biztest_cust")
|
||||
BIZ_PID = 99002
|
||||
|
||||
|
||||
def cleanup():
|
||||
for pat in ("biztest_biz_*", "biztest_cust_*"):
|
||||
for p in DATA.glob(pat):
|
||||
p.unlink()
|
||||
|
||||
|
||||
async def wait_until(fn, timeout=120, every=1):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
v = await fn()
|
||||
if v:
|
||||
return v
|
||||
await asyncio.sleep(every)
|
||||
return None
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
cleanup()
|
||||
addr_box = {}
|
||||
|
||||
async def on_address(pid, addr):
|
||||
addr_box["addr"] = addr
|
||||
|
||||
profile = {
|
||||
"id": BIZ_PID, "name": "biztestco", "bot_type": "business",
|
||||
"db_prefix": BIZ_PREFIX,
|
||||
"config": json.dumps({"welcome_message": "Thanks for contacting us!"}),
|
||||
}
|
||||
cust = None
|
||||
ok = True
|
||||
try:
|
||||
await pm.start_bot(profile, on_address)
|
||||
addr = await wait_until(lambda: asyncio.sleep(0, addr_box.get("addr")), timeout=90)
|
||||
print("business address:", bool(addr))
|
||||
assert addr, "business profile never published an address"
|
||||
|
||||
cust = await ChatApi.init(SqliteDb(file_prefix=CUST_PREFIX))
|
||||
if not await cust.api_get_active_user():
|
||||
await cust.api_create_active_user({"displayName": "customer", "fullName": ""})
|
||||
await cust.start_chat()
|
||||
await cust.send_chat_cmd(f"/connect {addr}")
|
||||
|
||||
u = await cust.api_get_active_user()
|
||||
uid = u["userId"]
|
||||
|
||||
async def customer_groups():
|
||||
return await cust.api_list_groups(uid)
|
||||
|
||||
groups = await wait_until(lambda: customer_groups(), timeout=90, every=2)
|
||||
contacts = await cust.api_list_contacts(uid)
|
||||
print("customer groups:", [g.get("groupProfile", {}).get("displayName") for g in (groups or [])])
|
||||
print("customer direct contacts:", [c.get("localDisplayName") for c in contacts])
|
||||
|
||||
# business address ⇒ a business GROUP chat, not a plain direct contact
|
||||
assert groups, "customer did not land in a business group (businessAddress not in effect?)"
|
||||
ok = bool(groups)
|
||||
except AssertionError as e:
|
||||
ok = False
|
||||
print("ASSERT FAIL:", e)
|
||||
finally:
|
||||
await pm.stop_bot(BIZ_PID)
|
||||
if cust:
|
||||
try:
|
||||
await cust.close()
|
||||
except Exception:
|
||||
pass
|
||||
cleanup()
|
||||
|
||||
print("\nRESULT:", "PASS — business profile creates per-customer group chats" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
116
manager/crypto_test.py
Normal file
116
manager/crypto_test.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""End-to-end test of the crypto price bot (Pattern 3, in-process FFI).
|
||||
|
||||
Serves a mock CoinGecko simple/price endpoint, starts a crypto bot, and checks it
|
||||
creates a channel and posts a price snapshot of the selected coins/currencies.
|
||||
|
||||
Run: .venv/bin/python crypto_test.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import profiles as pm # noqa: E402
|
||||
|
||||
DATA = Path("data")
|
||||
BOT_PREFIX = str(DATA / "cryptotest_bot")
|
||||
BOT_PID = 99005
|
||||
|
||||
PRICES = {"bitcoin": {"usd": 65000, "gbp": 51000}, "ethereum": {"usd": 3200, "gbp": 2500}}
|
||||
|
||||
|
||||
class CGHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = json.dumps(PRICES).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def cleanup():
|
||||
for p in DATA.glob("cryptotest_bot_*"):
|
||||
p.unlink()
|
||||
|
||||
|
||||
async def wait_until(fn, timeout=120, every=1):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
v = await fn()
|
||||
if v:
|
||||
return v
|
||||
await asyncio.sleep(every)
|
||||
return None
|
||||
|
||||
|
||||
async def channel_texts(chat, gid):
|
||||
c = await chat.api_get_chat("group", gid, 50)
|
||||
return [ci.get("content", {}).get("msgContent", {}).get("text", "") for ci in c.get("chatItems", [])]
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
cleanup()
|
||||
srv = HTTPServer(("127.0.0.1", 0), CGHandler)
|
||||
port = srv.server_address[1]
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
|
||||
# point the bot's fetcher at the mock server
|
||||
base = f"http://127.0.0.1:{port}/"
|
||||
orig_fetch = pm._fetch_crypto
|
||||
|
||||
def mock_fetch(ids, vs):
|
||||
with urllib.request.urlopen(base, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
pm._fetch_crypto = mock_fetch
|
||||
print("mock CoinGecko on", base)
|
||||
|
||||
profile = {
|
||||
"id": BOT_PID, "name": "cryptotestbot", "bot_type": "crypto",
|
||||
"db_prefix": BOT_PREFIX,
|
||||
"config": json.dumps({"coins": ["bitcoin", "ethereum"],
|
||||
"currencies": ["usd", "gbp"], "poll_seconds": 60}),
|
||||
}
|
||||
ok = True
|
||||
try:
|
||||
await pm.start_bot(profile, lambda pid, addr: asyncio.sleep(0))
|
||||
b = pm.get_running(BOT_PID)
|
||||
gid = await wait_until(lambda: asyncio.sleep(0, b.channel_gid), timeout=90)
|
||||
print("channel created:", bool(gid), "gid", gid)
|
||||
assert gid, "crypto bot did not create a channel"
|
||||
|
||||
got = await wait_until(
|
||||
lambda: _has_price(channel_texts(b.chat, gid)), timeout=30, every=2
|
||||
)
|
||||
print("price snapshot posted:", got)
|
||||
assert got and "Bitcoin" in got and "$" in got, "no valid price snapshot posted"
|
||||
except AssertionError as e:
|
||||
ok = False
|
||||
print("ASSERT FAIL:", e)
|
||||
finally:
|
||||
pm._fetch_crypto = orig_fetch
|
||||
await pm.stop_bot(BOT_PID)
|
||||
srv.shutdown()
|
||||
cleanup()
|
||||
|
||||
print("\nRESULT:", "PASS — crypto bot posts price snapshots" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
async def _has_price(coro):
|
||||
for t in await coro:
|
||||
if "Crypto prices" in (t or ""):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -61,6 +61,13 @@ def update_address(profile_id: int, address: str) -> None:
|
||||
conn.execute("UPDATE profiles SET address=? WHERE id=?", (address, profile_id))
|
||||
|
||||
|
||||
def update_config(profile_id: int, config: dict) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE profiles SET config=? WHERE id=?", (json.dumps(config), profile_id)
|
||||
)
|
||||
|
||||
|
||||
def delete_profile(profile_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM profiles WHERE id=?", (profile_id,))
|
||||
|
||||
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)
|
||||
150
manager/llm_test.py
Normal file
150
manager/llm_test.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""End-to-end test of the 'llm' bot (Pattern 3, in-process FFI).
|
||||
|
||||
Stands up a tiny OpenAI-compatible mock server (so no Ollama needed), starts an
|
||||
llm bot pointed at it with a known context, connects a customer, sends a message,
|
||||
and verifies the bot's reply reflects both the configured context and the message.
|
||||
|
||||
Run: .venv/bin/python llm_test.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import profiles as pm # noqa: E402
|
||||
from simplex_chat import ChatApi, SqliteDb # noqa: E402
|
||||
|
||||
DATA = Path("data")
|
||||
BOT_PREFIX = str(DATA / "llmtest_bot")
|
||||
CUST_PREFIX = str(DATA / "llmtest_cust")
|
||||
BOT_PID = 99003
|
||||
CONTEXT = "TESTCTX"
|
||||
|
||||
|
||||
def cleanup():
|
||||
for pat in ("llmtest_bot_*", "llmtest_cust_*"):
|
||||
for p in DATA.glob(pat):
|
||||
p.unlink()
|
||||
|
||||
|
||||
class MockLLM(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(n) or b"{}")
|
||||
msgs = body.get("messages", [])
|
||||
system = next((m["content"] for m in msgs if m["role"] == "system"), "")
|
||||
last_user = next((m["content"] for m in reversed(msgs) if m["role"] == "user"), "")
|
||||
content = f"ctx:{system}|got:{last_user}"
|
||||
out = json.dumps({"choices": [{"message": {"role": "assistant", "content": content}}]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(out)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
async def wait_until(fn, timeout=120, every=1):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
v = await fn()
|
||||
if v:
|
||||
return v
|
||||
await asyncio.sleep(every)
|
||||
return None
|
||||
|
||||
|
||||
async def incoming_texts(chat, contact_id):
|
||||
c = await chat.api_get_chat("direct", contact_id, 50)
|
||||
return [
|
||||
ci.get("content", {}).get("msgContent", {}).get("text", "")
|
||||
for ci in c.get("chatItems", [])
|
||||
if ci.get("chatDir", {}).get("type", "").endswith("Rcv")
|
||||
]
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
cleanup()
|
||||
srv = HTTPServer(("127.0.0.1", 0), MockLLM)
|
||||
port = srv.server_address[1]
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
print("mock LLM on port", port)
|
||||
|
||||
addr_box = {}
|
||||
|
||||
async def on_address(pid, addr):
|
||||
addr_box["addr"] = addr
|
||||
|
||||
profile = {
|
||||
"id": BOT_PID, "name": "llmtestbot", "bot_type": "llm",
|
||||
"db_prefix": BOT_PREFIX,
|
||||
"config": json.dumps({
|
||||
"api_base": f"http://127.0.0.1:{port}/v1",
|
||||
"model": "test-model", "api_key": "x", "system_prompt": CONTEXT,
|
||||
}),
|
||||
}
|
||||
cust = None
|
||||
ok = True
|
||||
try:
|
||||
await pm.start_bot(profile, on_address)
|
||||
addr = await wait_until(lambda: asyncio.sleep(0, addr_box.get("addr")), timeout=90)
|
||||
print("bot address:", bool(addr))
|
||||
assert addr, "llm bot never published an address"
|
||||
|
||||
cust = await ChatApi.init(SqliteDb(file_prefix=CUST_PREFIX))
|
||||
if not await cust.api_get_active_user():
|
||||
await cust.api_create_active_user({"displayName": "customer", "fullName": ""})
|
||||
await cust.start_chat()
|
||||
await cust.send_chat_cmd(f"/connect {addr}")
|
||||
|
||||
u = await cust.api_get_active_user()
|
||||
cid = await wait_until(
|
||||
lambda: _first_contact(cust, u["userId"]), timeout=90, every=2
|
||||
)
|
||||
assert cid, "customer did not connect"
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await cust.api_send_text_message({"chatType": "direct", "chatId": cid}, "ping")
|
||||
reply = await wait_until(
|
||||
lambda: _find_reply(cust, cid), timeout=60, every=2
|
||||
)
|
||||
print("bot reply:", reply)
|
||||
assert reply and "ctx:TESTCTX" in reply and "got:ping" in reply, \
|
||||
"reply did not reflect context + message"
|
||||
except AssertionError as e:
|
||||
ok = False
|
||||
print("ASSERT FAIL:", e)
|
||||
finally:
|
||||
await pm.stop_bot(BOT_PID)
|
||||
if cust:
|
||||
try:
|
||||
await cust.close()
|
||||
except Exception:
|
||||
pass
|
||||
srv.shutdown()
|
||||
cleanup()
|
||||
|
||||
print("\nRESULT:", "PASS — llm bot replies using its context" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
async def _first_contact(chat, uid):
|
||||
cs = await chat.api_list_contacts(uid)
|
||||
return cs[0]["contactId"] if cs else None
|
||||
|
||||
|
||||
async def _find_reply(chat, cid):
|
||||
for t in await incoming_texts(chat, cid):
|
||||
if t.startswith("ctx:"):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
244
manager/main.py
244
manager/main.py
@@ -7,8 +7,10 @@ import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import mimetypes
|
||||
|
||||
from fastapi import FastAPI, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
@@ -37,6 +39,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="SimpleX Manager", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
||||
# Generated directory-bot websites (one folder per directory bot) — preview/host.
|
||||
WEB_DIR = BASE.parent / "web"
|
||||
WEB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/directory", StaticFiles(directory=str(WEB_DIR), html=True), name="directory")
|
||||
|
||||
|
||||
async def _save_address(profile_id: int, address: str) -> None:
|
||||
@@ -66,6 +72,17 @@ def _enrich(profiles: list[dict]) -> list[dict]:
|
||||
return profiles
|
||||
|
||||
|
||||
def _category(bot_type: str) -> str:
|
||||
"""Which sidebar category a profile belongs to: users / businesses / rss-bots / bots."""
|
||||
if bot_type in pm.BUSINESS_TYPES:
|
||||
return "businesses"
|
||||
if bot_type in pm.RSS_TYPES:
|
||||
return "rss-bots"
|
||||
if bot_type in pm.USER_TYPES:
|
||||
return "users"
|
||||
return "bots"
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
@@ -93,7 +110,9 @@ async def logout():
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return RedirectResponse("/users", status_code=302)
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
return TEMPLATES.TemplateResponse(request, "home.html", {"nav_active": "home"})
|
||||
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
@@ -107,6 +126,17 @@ async def users_page(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@app.get("/businesses", response_class=HTMLResponse)
|
||||
async def businesses_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.BUSINESS_TYPES])
|
||||
return TEMPLATES.TemplateResponse(request, "list.html", {
|
||||
"tab": "businesses", "items": items, "create_types": pm.BUSINESS_TYPES,
|
||||
"nav_active": "businesses",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/bots", response_class=HTMLResponse)
|
||||
async def bots_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
@@ -118,11 +148,81 @@ async def bots_page(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@app.get("/rss-bots", response_class=HTMLResponse)
|
||||
async def rss_bots_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.RSS_TYPES])
|
||||
return TEMPLATES.TemplateResponse(request, "list.html", {
|
||||
"tab": "rss-bots", "items": items, "create_types": pm.RSS_TYPES,
|
||||
"nav_active": "rss-bots",
|
||||
})
|
||||
|
||||
|
||||
RELAY_KINDS = {"chat": "Chat Relay", "file": "File Relay", "message": "Message Relay"}
|
||||
|
||||
|
||||
@app.get("/relays/{kind}", response_class=HTMLResponse)
|
||||
async def relay_page(request: Request, kind: str):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
if kind not in RELAY_KINDS:
|
||||
raise HTTPException(status_code=404, detail="Unknown relay")
|
||||
return TEMPLATES.TemplateResponse(request, "relay.html", {
|
||||
"nav_active": "relays", "kind": kind, "title": RELAY_KINDS[kind],
|
||||
})
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
return TEMPLATES.TemplateResponse(request, "settings.html", {"nav_active": "settings"})
|
||||
return TEMPLATES.TemplateResponse(request, "settings.html", {
|
||||
"nav_active": "settings",
|
||||
"network": await pm.get_network_config(),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/network", response_class=HTMLResponse)
|
||||
async def network_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
return TEMPLATES.TemplateResponse(request, "network.html", {
|
||||
"detail": await pm.get_servers_detail(),
|
||||
"nav_active": "network",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/notifications", response_class=HTMLResponse)
|
||||
async def notifications_page(request: Request):
|
||||
if redir := _redirect_if_unauth(request):
|
||||
return redir
|
||||
return TEMPLATES.TemplateResponse(request, "notifications.html", {
|
||||
"items": pm.get_notifications(100),
|
||||
"nav_active": "notifications",
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def api_status(request: Request):
|
||||
_require_auth(request)
|
||||
status = await pm.global_status()
|
||||
status["profiles_total"] = len(db.list_profiles())
|
||||
status["online"] = status["profiles_running"] > 0
|
||||
return JSONResponse(status)
|
||||
|
||||
|
||||
@app.get("/api/notifications")
|
||||
async def api_notifications(request: Request):
|
||||
_require_auth(request)
|
||||
return JSONResponse({"unread": pm.unread_count(), "items": pm.get_notifications(50)})
|
||||
|
||||
|
||||
@app.post("/api/notifications/read")
|
||||
async def api_notifications_read(request: Request):
|
||||
_require_auth(request)
|
||||
pm.mark_all_read()
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/profile/{profile_id}", response_class=HTMLResponse)
|
||||
@@ -134,11 +234,13 @@ async def profile_page(request: Request, profile_id: int):
|
||||
raise HTTPException(404, "Profile not found")
|
||||
profile["config"] = json.loads(profile.get("config") or "{}")
|
||||
profile["running"] = pm.is_running(profile_id)
|
||||
# Refresh cached lists so member counts / contacts are current on view
|
||||
await pm.refresh_lists(profile_id)
|
||||
bot = pm.get_running(profile_id)
|
||||
contacts = bot.contacts if bot else []
|
||||
groups = bot.groups if bot else []
|
||||
log_lines = bot.log_lines[-50:] if bot else []
|
||||
is_user = profile["bot_type"] in pm.USER_TYPES
|
||||
cat = _category(profile["bot_type"])
|
||||
# Split groups by their link role: channels (observer) vs regular groups (member).
|
||||
# The is_channel flag is set during the bot's group refresh (see _classify_group).
|
||||
channels = [g for g in groups if g.get("is_channel")]
|
||||
@@ -149,8 +251,8 @@ async def profile_page(request: Request, profile_id: int):
|
||||
"groups": plain_groups,
|
||||
"channels": channels,
|
||||
"log_lines": log_lines,
|
||||
"back": "/users" if is_user else "/bots",
|
||||
"nav_active": "users" if is_user else "bots",
|
||||
"back": "/" + cat,
|
||||
"nav_active": cat,
|
||||
})
|
||||
|
||||
|
||||
@@ -187,7 +289,6 @@ async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id:
|
||||
if pm.group_id(g) == chat_id:
|
||||
is_channel = bool(g.get("is_channel"))
|
||||
break
|
||||
is_user = profile["bot_type"] in pm.USER_TYPES
|
||||
return TEMPLATES.TemplateResponse(request, "chat.html", {
|
||||
"profile": profile,
|
||||
"running": pm.is_running(profile_id),
|
||||
@@ -195,7 +296,7 @@ async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id:
|
||||
"chat_id": chat_id,
|
||||
"chat_name": name,
|
||||
"is_channel": is_channel,
|
||||
"nav_active": "users" if is_user else "bots",
|
||||
"nav_active": _category(profile["bot_type"]),
|
||||
})
|
||||
|
||||
|
||||
@@ -217,7 +318,78 @@ async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id:
|
||||
text = data.get("text", "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "text required")
|
||||
ok = await pm.send_to_chat(profile_id, chat_type, chat_id, text)
|
||||
reply_to_id = data.get("reply_to_id")
|
||||
try:
|
||||
await pm.send_to_chat(profile_id, chat_type, chat_id, text, reply_to_id=reply_to_id)
|
||||
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}/react")
|
||||
async def chat_react(request: Request, profile_id: int, chat_type: str, chat_id: int):
|
||||
_require_auth(request)
|
||||
data = await request.json()
|
||||
item_id = data.get("item_id")
|
||||
emoji = data.get("emoji", "")
|
||||
add = data.get("add", True)
|
||||
if not item_id or not emoji:
|
||||
raise HTTPException(400, "item_id and emoji required")
|
||||
try:
|
||||
await pm.send_reaction(profile_id, chat_type, chat_id, item_id, emoji, add)
|
||||
except Exception as e:
|
||||
log.error("react failed: %s", e)
|
||||
return JSONResponse({"ok": False, "error": str(e)})
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.post("/api/profiles/{profile_id}/file/{file_id}/receive")
|
||||
async def file_receive(request: Request, profile_id: int, file_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
await pm.accept_file(profile_id, file_id)
|
||||
except Exception as e:
|
||||
log.error("file receive failed: %s", e)
|
||||
return JSONResponse({"ok": False, "error": str(e)})
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/profiles/{profile_id}/file/{file_id}/download")
|
||||
async def file_download(request: Request, profile_id: int, file_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
data, filename = await pm.read_file_bytes(profile_id, file_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except Exception as e:
|
||||
log.error("file download failed: %s", e)
|
||||
raise HTTPException(500, str(e))
|
||||
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=mime,
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/clear")
|
||||
async def chat_clear(request: Request, profile_id: int, chat_type: str, chat_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
ok = await pm.clear_chat(profile_id, chat_type, chat_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@app.delete("/api/profiles/{profile_id}/contacts/{contact_id}")
|
||||
async def contact_delete(request: Request, profile_id: int, contact_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
ok = await pm.delete_contact(profile_id, contact_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@@ -238,9 +410,32 @@ async def create_profile(request: Request):
|
||||
profile = db.create_profile(name, bot_type, config)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
# Directory bots get their own auto-generated listing website
|
||||
if bot_type == "directory":
|
||||
try:
|
||||
profile["site"] = pm.generate_directory_site(name)
|
||||
except Exception as e:
|
||||
log.error("directory site generation failed: %s", e)
|
||||
return JSONResponse(profile, status_code=201)
|
||||
|
||||
|
||||
@app.post("/api/profiles/{profile_id}/profile")
|
||||
async def edit_profile(request: Request, profile_id: int):
|
||||
_require_auth(request)
|
||||
if not db.get_profile(profile_id):
|
||||
raise HTTPException(404, "Profile not found")
|
||||
data = await request.json()
|
||||
# Keys absent from the body are left unchanged; avatar="" removes the avatar.
|
||||
full_name = data.get("full_name")
|
||||
bio = data.get("bio")
|
||||
avatar = data.get("avatar") # None = unchanged, "" = remove, str = replace
|
||||
try:
|
||||
config = await pm.update_profile(profile_id, full_name, bio, avatar)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": True, "config": config})
|
||||
|
||||
|
||||
@app.delete("/api/profiles/{profile_id}")
|
||||
async def delete_profile(request: Request, profile_id: int):
|
||||
_require_auth(request)
|
||||
@@ -279,6 +474,7 @@ async def profile_status(request: Request, profile_id: int):
|
||||
"contacts": len(bot.contacts) if bot else 0,
|
||||
"groups": len(bot.groups) if bot else 0,
|
||||
"log": bot.log_lines[-20:] if bot else [],
|
||||
"poll_next": bot.poll_next if bot else 0,
|
||||
})
|
||||
|
||||
|
||||
@@ -318,6 +514,36 @@ async def create_group(request: Request, profile_id: int):
|
||||
return JSONResponse({"ok": True, "link": link})
|
||||
|
||||
|
||||
@app.post("/api/profiles/{profile_id}/groups/{group_id}/join")
|
||||
async def group_join(request: Request, profile_id: int, group_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
ok = await pm.join_group(profile_id, group_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@app.post("/api/profiles/{profile_id}/groups/{group_id}/leave")
|
||||
async def group_leave(request: Request, profile_id: int, group_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
ok = await pm.leave_group(profile_id, group_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@app.delete("/api/profiles/{profile_id}/groups/{group_id}")
|
||||
async def group_delete(request: Request, profile_id: int, group_id: int):
|
||||
_require_auth(request)
|
||||
try:
|
||||
ok = await pm.delete_group(profile_id, group_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return JSONResponse({"ok": ok})
|
||||
|
||||
|
||||
@app.get("/api/profiles/{profile_id}/groups/{group_id}/members")
|
||||
async def group_members(request: Request, profile_id: int, group_id: int):
|
||||
_require_auth(request)
|
||||
|
||||
1092
manager/profiles.py
1092
manager/profiles.py
File diff suppressed because it is too large
Load Diff
134
manager/rss_test.py
Normal file
134
manager/rss_test.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""End-to-end test of the RSS bot (Pattern 3, in-process FFI).
|
||||
|
||||
Serves a mock RSS feed locally, starts an rss bot pointed at it, and checks:
|
||||
- the bot creates a broadcast channel (observer group)
|
||||
- the initial feed item is seeded (not re-posted)
|
||||
- a newly-added feed item is broadcast to the channel
|
||||
|
||||
Verifies via the bot's own view of the channel chat (no subscriber needed).
|
||||
|
||||
Run: .venv/bin/python rss_test.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import profiles as pm # noqa: E402
|
||||
|
||||
DATA = Path("data")
|
||||
BOT_PREFIX = str(DATA / "rsstest_bot")
|
||||
BOT_PID = 99004
|
||||
|
||||
# mutable feed state the mock server serves
|
||||
FEED = {"items": [("First post", "https://example.com/1")]}
|
||||
|
||||
|
||||
def feed_xml():
|
||||
items = "".join(
|
||||
f"<item><title>{t}</title><link>{l}</link><guid>{l}</guid></item>"
|
||||
for t, l in FEED["items"]
|
||||
)
|
||||
return (f'<?xml version="1.0"?><rss version="2.0"><channel>'
|
||||
f'<title>Test Feed</title>{items}</channel></rss>').encode()
|
||||
|
||||
|
||||
class FeedHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = feed_xml()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/rss+xml")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def cleanup():
|
||||
for p in DATA.glob("rsstest_bot_*"):
|
||||
p.unlink()
|
||||
bf = DATA / "manager.db" # leave the manager db alone; we don't use it here
|
||||
|
||||
|
||||
async def wait_until(fn, timeout=120, every=1):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
v = await fn()
|
||||
if v:
|
||||
return v
|
||||
await asyncio.sleep(every)
|
||||
return None
|
||||
|
||||
|
||||
async def channel_texts(chat, gid):
|
||||
c = await chat.api_get_chat("group", gid, 50)
|
||||
out = []
|
||||
for ci in c.get("chatItems", []):
|
||||
out.append(ci.get("content", {}).get("msgContent", {}).get("text", ""))
|
||||
return out
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
cleanup()
|
||||
srv = HTTPServer(("127.0.0.1", 0), FeedHandler)
|
||||
port = srv.server_address[1]
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
url = f"http://127.0.0.1:{port}/feed.xml"
|
||||
print("mock feed at", url)
|
||||
|
||||
async def on_address(pid, addr):
|
||||
pass
|
||||
|
||||
profile = {
|
||||
"id": BOT_PID, "name": "rsstestbot", "bot_type": "rss",
|
||||
"db_prefix": BOT_PREFIX,
|
||||
"config": json.dumps({"feed_url": url, "poll_seconds": 5}),
|
||||
}
|
||||
ok = True
|
||||
try:
|
||||
await pm.start_bot(profile, on_address)
|
||||
b = pm.get_running(BOT_PID)
|
||||
|
||||
# 1) channel created
|
||||
gid = await wait_until(lambda: asyncio.sleep(0, b.channel_gid), timeout=90)
|
||||
print("channel created:", bool(gid), "gid", gid)
|
||||
assert gid, "rss bot did not create a channel"
|
||||
|
||||
# 2) first run populates the channel with the existing item(s)
|
||||
got_initial = await wait_until(
|
||||
lambda: _contains(channel_texts(b.chat, gid), "First post"), timeout=20, every=2
|
||||
)
|
||||
print("initial item populated to channel:", bool(got_initial))
|
||||
assert got_initial, "channel was not populated on first run"
|
||||
|
||||
# 3) add a new item → it should be broadcast on the next poll
|
||||
FEED["items"].insert(0, ("Breaking news", "https://example.com/2"))
|
||||
got = await wait_until(
|
||||
lambda: _contains(channel_texts(b.chat, gid), "Breaking news"), timeout=30, every=2
|
||||
)
|
||||
print("new item broadcast to channel:", bool(got))
|
||||
ok = bool(got)
|
||||
except AssertionError as e:
|
||||
ok = False
|
||||
print("ASSERT FAIL:", e)
|
||||
finally:
|
||||
await pm.stop_bot(BOT_PID)
|
||||
srv.shutdown()
|
||||
cleanup()
|
||||
|
||||
print("\nRESULT:", "PASS — rss bot broadcasts new feed posts" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
async def _contains(coro, needle):
|
||||
return any(needle in (t or "") for t in await coro)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
@@ -23,4 +23,4 @@ echo " URL: http://0.0.0.0:8000"
|
||||
echo " Token: $MANAGER_TOKEN"
|
||||
echo ""
|
||||
|
||||
exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
23
manager/templates/_macros.html
Normal file
23
manager/templates/_macros.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{# Reusable SimpleX link box: a "Link" and a "QR" toggle button, both hidden by default.
|
||||
linkbtns(id) — just the two toggle buttons (for table action cells)
|
||||
linkpanels(link,id) — the hidden link + QR containers (place where they can span)
|
||||
linkbox(link,id) — buttons + panels together (for block contexts)
|
||||
`id` must be unique on the page. JS lives in base.html (sxToggleLink/sxToggleQr/sxCopy). #}
|
||||
|
||||
{% macro linkbtns(id) %}
|
||||
<button class="lb-btn" onclick="sxToggleLink('{{ id }}', this)"><i class="fa-solid fa-link"></i> Link</button>
|
||||
<button class="lb-btn" onclick="sxToggleQr('{{ id }}', this)"><i class="fa-solid fa-qrcode"></i> QR</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro linkpanels(link, id) %}
|
||||
<div id="lb-link-{{ id }}" class="lb-link" style="display:none;">
|
||||
<button class="btn btn-ghost copy-btn" title="Copy" onclick="sxCopy('{{ id }}', this)"><i class="fa-solid fa-copy"></i></button>
|
||||
<a class="addr-link" id="lb-url-{{ id }}" href="{{ link }}" target="_blank" rel="noopener">{{ link }}</a>
|
||||
</div>
|
||||
<div id="lb-qr-{{ id }}" class="lb-qr" style="display:none;"><canvas id="lb-qrc-{{ id }}"></canvas></div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro linkbox(link, id) %}
|
||||
<div class="flex gap-8">{{ linkbtns(id) }}</div>
|
||||
{{ linkpanels(link, id) }}
|
||||
{% endmacro %}
|
||||
@@ -12,7 +12,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}SimpleX Manager{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('htmx:configRequest', function(evt) {
|
||||
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
|
||||
@@ -76,9 +80,16 @@
|
||||
--badge-red-text: #ff6b6b;
|
||||
}
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
body { font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
|
||||
/* Material-ish elevation: cards lift slightly on hover */
|
||||
.card { transition: box-shadow 0.18s ease, transform 0.05s ease; }
|
||||
.card:hover { box-shadow: 0 4px 18px rgba(0,0,0,0.14); }
|
||||
[data-theme="matrix"] .card:hover { box-shadow: 0 0 18px rgba(0,255,65,0.22); }
|
||||
.btn { box-shadow: 0 1px 3px rgba(0,0,0,0.18); letter-spacing: 0.2px; }
|
||||
.btn-ghost { box-shadow: none; }
|
||||
|
||||
[data-theme="matrix"] body {
|
||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||
text-shadow: 0 0 2px rgba(0,255,65,0.4);
|
||||
@@ -92,40 +103,73 @@
|
||||
background: var(--card); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh;
|
||||
overflow-y: auto; /* scroll within the sidebar if it's taller than the screen */
|
||||
transition: width 0.2s ease, transform 0.2s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
html.collapsed .sidebar { width: 64px; }
|
||||
|
||||
.side-head { display: flex; align-items: center; border-bottom: 1px solid var(--border); }
|
||||
.collapse-toggle {
|
||||
flex-shrink: 0; background: none; border: none; cursor: pointer;
|
||||
color: var(--muted); font-size: 17px; padding: 18px;
|
||||
}
|
||||
.collapse-toggle:hover { color: var(--text); }
|
||||
.nav-brand {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 18px; font-size: 16px; font-weight: 700;
|
||||
padding: 18px 18px 18px 0; font-size: 16px; font-weight: 700;
|
||||
color: var(--accent); text-decoration: none;
|
||||
border-bottom: 1px solid var(--border); white-space: nowrap; overflow: hidden;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.nav-brand .brand-icon { font-size: 18px; flex-shrink: 0; }
|
||||
html.collapsed .nav-brand { display: none; }
|
||||
html.collapsed .side-head { justify-content: center; }
|
||||
|
||||
.side-nav { display: flex; flex-direction: column; padding: 8px 0; }
|
||||
.side-nav a {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 18px; color: var(--muted); text-decoration: none;
|
||||
font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.notif-badge {
|
||||
margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px;
|
||||
border-radius: 9px; background: var(--red); color: #fff;
|
||||
font-size: 11px; font-weight: 700;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
html.collapsed .side-nav .notif-badge {
|
||||
position: absolute; top: 5px; right: 8px; margin: 0;
|
||||
min-width: 16px; height: 16px; font-size: 10px; padding: 0 4px;
|
||||
}
|
||||
.side-nav a:hover { color: var(--text); background: var(--bg); }
|
||||
.side-nav a.active { color: var(--accent); border-left-color: var(--accent); }
|
||||
.side-nav .ico { width: 20px; text-align: center; font-size: 16px; flex-shrink: 0; }
|
||||
.side-nav a.nav-sep { margin-top: 10px; padding-top: 17px; border-top: 1px solid var(--border); }
|
||||
|
||||
.side-foot { margin-top: auto; padding: 8px 0; border-top: 1px solid var(--border); }
|
||||
.collapse-btn {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
padding: 11px 18px; background: none; border: none; cursor: pointer;
|
||||
color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.collapse-btn:hover { color: var(--text); }
|
||||
.collapse-btn .ico { width: 20px; text-align: center; flex-shrink: 0; }
|
||||
|
||||
.side-clock { padding: 12px 18px 10px; border-bottom: 1px solid var(--border); text-align: center; }
|
||||
.side-clock .clk-time { font-size: 20px; font-weight: 700; color: var(--text);
|
||||
font-variant-numeric: tabular-nums; letter-spacing: 0.5px; }
|
||||
.side-clock .clk-date { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
html.collapsed .side-clock { padding: 10px 0; }
|
||||
html.collapsed .side-clock .clk-time { font-size: 12px; }
|
||||
html.collapsed .side-clock .clk-date { display: none; }
|
||||
|
||||
.side-status { padding: 10px 18px 12px; font-size: 12px; color: var(--muted);
|
||||
border-bottom: 1px solid var(--border); }
|
||||
.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); }
|
||||
/* collapsed: keep only the status dot, centered */
|
||||
html.collapsed .side-status { padding: 12px 0; }
|
||||
html.collapsed .side-status .ss-text { display: none; }
|
||||
html.collapsed .side-status .ss-row { justify-content: center; margin-top: 0; }
|
||||
html.collapsed .side-status .ss-row.ss-text { display: none; }
|
||||
|
||||
html.collapsed .lbl, html.collapsed .brand-text { display: none; }
|
||||
|
||||
@@ -152,15 +196,19 @@
|
||||
/* ── Mobile: off-canvas sidebar ─────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed; left: 0; top: 0; height: 100vh; width: 240px;
|
||||
position: fixed; left: 0; top: 0; width: 240px;
|
||||
height: 100vh; height: 100dvh; /* dvh tracks the visible area incl. browser toolbar */
|
||||
overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
html.collapsed .sidebar { width: 240px; } /* ignore collapse on mobile */
|
||||
html.collapsed .lbl, html.collapsed .brand-text { display: inline; }
|
||||
html.collapsed .nav-brand { display: flex; } /* keep brand visible on mobile */
|
||||
html.collapsed .side-head { justify-content: flex-start; }
|
||||
body.sidebar-open .sidebar { transform: translateX(0); }
|
||||
body.sidebar-open .backdrop { display: block; }
|
||||
.mobile-menu-btn { display: flex; }
|
||||
.collapse-btn { display: none; }
|
||||
.collapse-toggle { display: none; } /* mobile uses the drawer toggle instead */
|
||||
.container { padding-top: 64px; }
|
||||
}
|
||||
|
||||
@@ -232,28 +280,64 @@
|
||||
dialog { background: var(--card); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
|
||||
dialog::backdrop { background: rgba(0,0,0,0.5); }
|
||||
|
||||
/* reusable SimpleX link box: Link + QR toggles, both hidden by default */
|
||||
.lb-btn { display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; font-size: 12px; font-weight: 600; border-radius: 6px;
|
||||
background: transparent; border: 1px solid var(--border); color: var(--accent);
|
||||
cursor: pointer; font-family: inherit; }
|
||||
.lb-btn:hover { background: var(--bg); }
|
||||
.lb-btn.on { background: var(--accent); color: var(--btn-light-text); border-color: var(--accent); }
|
||||
.lb-link { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.lb-link .addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace;
|
||||
font-size: 12px; text-decoration: none; word-break: break-all; }
|
||||
.lb-link .addr-link:hover { color: var(--accent); text-decoration: underline; }
|
||||
.lb-qr { margin-top: 10px; }
|
||||
.lb-qr canvas { background: #fff; border-radius: 8px; padding: 8px; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<button class="mobile-menu-btn" onclick="toggleSidebar()" aria-label="Menu">☰</button>
|
||||
<button class="mobile-menu-btn" onclick="toggleSidebar()" aria-label="Menu"><i class="fa-solid fa-bars"></i></button>
|
||||
<div class="app">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<a class="nav-brand" href="/users">
|
||||
<span class="brand-icon">◆</span><span class="brand-text">SimpleX Manager</span>
|
||||
<div class="side-head">
|
||||
<button class="collapse-toggle" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar"><i class="fa-solid fa-bars"></i></button>
|
||||
<a class="nav-brand" href="/">
|
||||
<span class="brand-text">SimpleX Manager</span>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico">👤</span><span class="lbl">Users</span></a>
|
||||
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico">🤖</span><span class="lbl">Bots</span></a>
|
||||
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico">📁</span><span class="lbl">File upload</span></a>
|
||||
<a href="/settings" class="nav-sep {% if nav_active == 'settings' %}active{% endif %}"><span class="ico">⚙️</span><span class="lbl">Settings</span></a>
|
||||
<!-- Group 1: accounts -->
|
||||
<a href="/users" {% if nav_active == 'users' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-user"></i></span><span class="lbl">Users</span></a>
|
||||
<a href="/bots" {% if nav_active == 'bots' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-robot"></i></span><span class="lbl">Bots</span></a>
|
||||
<a href="/businesses" {% if nav_active == 'businesses' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-briefcase"></i></span><span class="lbl">Business Groups</span></a>
|
||||
<a href="https://simplex.chat/file/" target="_blank" rel="noopener"><span class="ico"><i class="fa-solid fa-upload"></i></span><span class="lbl">File Upload</span></a>
|
||||
<a href="/rss-bots" {% if nav_active == 'rss-bots' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-rss"></i></span><span class="lbl">RSS</span></a>
|
||||
<!-- Group 2: relays -->
|
||||
<a href="/relays/chat" class="nav-sep {% if nav_active == 'relays' and kind == 'chat' %}active{% endif %}"><span class="ico"><i class="fa-solid fa-comments"></i></span><span class="lbl">Chat Relay</span></a>
|
||||
<a href="/relays/file" {% if nav_active == 'relays' and kind == 'file' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-file-export"></i></span><span class="lbl">File Relay</span></a>
|
||||
<a href="/relays/message" {% if nav_active == 'relays' and kind == 'message' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-envelope"></i></span><span class="lbl">Message Relay</span></a>
|
||||
<!-- Group 3: system -->
|
||||
<a href="/network" class="nav-sep {% if nav_active == 'network' %}active{% endif %}"><span class="ico"><i class="fa-solid fa-tower-broadcast"></i></span><span class="lbl">Network</span></a>
|
||||
<a href="/notifications" {% if nav_active == 'notifications' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-bell"></i></span><span class="lbl">Notifications</span><span class="notif-badge" id="notif-badge" style="display:none;"></span></a>
|
||||
<a href="/settings" {% if nav_active == 'settings' %}class="active"{% endif %}><span class="ico"><i class="fa-solid fa-gear"></i></span><span class="lbl">Settings</span></a>
|
||||
<!-- Group 4: external -->
|
||||
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener" class="nav-sep"><span class="ico"><i class="fa-solid fa-download"></i></span><span class="lbl">Get App</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<button class="collapse-btn" onclick="toggleCollapse()" title="Collapse sidebar" aria-label="Collapse sidebar">
|
||||
<span class="ico" id="collapse-ico">‹</span>
|
||||
</button>
|
||||
<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-row"><span class="ss-dot" id="ss-dot"></span><span class="ss-text"><span id="ss-running">–/–</span> running</span></div>
|
||||
<div class="ss-row ss-text"><i class="fa-solid fa-server" style="width:14px;text-align:center;"></i> <span id="ss-servers">–</span></div>
|
||||
<div class="ss-row ss-text" id="ss-ops" style="opacity:0.8;"></div>
|
||||
</a>
|
||||
<div class="side-clock" id="side-clock">
|
||||
<div class="clk-time" id="clk-time">--:--</div>
|
||||
<div class="clk-date ss-text" id="clk-date">—</div>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<a href="/logout"><span class="ico">⏻</span><span class="lbl">Logout</span></a>
|
||||
<a href="/logout"><span class="ico"><i class="fa-solid fa-right-from-bracket"></i></span><span class="lbl">Logout</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -263,9 +347,9 @@
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
© Bournemouth Technology Ltd
|
||||
© <a href="https://bournemouthtechnology.co.uk" target="_blank" rel="noopener">Bournemouth Technology Ltd</a>
|
||||
<span class="sep">·</span>
|
||||
built on © SimpleX Network
|
||||
built on © <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX Network</a>
|
||||
<span class="sep">·</span>
|
||||
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a>
|
||||
</footer>
|
||||
@@ -277,14 +361,95 @@
|
||||
function toggleCollapse() {
|
||||
const collapsed = document.documentElement.classList.toggle('collapsed');
|
||||
localStorage.setItem('sidebar-collapsed', collapsed ? '1' : '');
|
||||
const ico = document.getElementById('collapse-ico');
|
||||
if (ico) ico.textContent = collapsed ? '›' : '‹';
|
||||
}
|
||||
// Sync collapse icon with restored state on load
|
||||
(function(){
|
||||
const ico = document.getElementById('collapse-ico');
|
||||
if (ico && document.documentElement.classList.contains('collapsed')) ico.textContent = '›';
|
||||
})();
|
||||
|
||||
// Clipboard that also works over plain-HTTP LAN (navigator.clipboard needs a secure context).
|
||||
function robustCopy(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||
}
|
||||
return Promise.resolve(fallbackCopy(text));
|
||||
}
|
||||
function fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function flashCheck(btn) {
|
||||
const o = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
||||
setTimeout(() => btn.innerHTML = o, 1500);
|
||||
}
|
||||
|
||||
// Reusable SimpleX link box: Link + QR toggles (both hidden by default).
|
||||
function _sxUrl(id) { const a = document.getElementById('lb-url-' + id); return a ? a.getAttribute('href') : ''; }
|
||||
function sxToggleLink(id, btn) {
|
||||
const el = document.getElementById('lb-link-' + id); if (!el) return;
|
||||
const show = el.style.display === 'none';
|
||||
el.style.display = show ? '' : 'none';
|
||||
btn.classList.toggle('on', show);
|
||||
}
|
||||
function sxToggleQr(id, btn) {
|
||||
const w = document.getElementById('lb-qr-' + id); if (!w) return;
|
||||
const show = w.style.display === 'none';
|
||||
w.style.display = show ? '' : 'none';
|
||||
btn.classList.toggle('on', show);
|
||||
if (show && !w.dataset.r && window.QRCode) {
|
||||
QRCode.toCanvas(document.getElementById('lb-qrc-' + id), _sxUrl(id), {width: 180}, () => {});
|
||||
w.dataset.r = '1';
|
||||
}
|
||||
}
|
||||
function sxCopy(id, btn) { robustCopy(_sxUrl(id)).then(() => flashCheck(btn)); }
|
||||
|
||||
// Sidebar clock: 24h time + day-of-week date
|
||||
function tickClock() {
|
||||
const now = new Date();
|
||||
const te = document.getElementById('clk-time');
|
||||
const de = document.getElementById('clk-date');
|
||||
if (te) te.textContent = now.toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit', hour12: false});
|
||||
if (de) de.textContent = now.toLocaleDateString('en-GB', {weekday: 'short', day: '2-digit', month: 'short', year: 'numeric'});
|
||||
}
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
|
||||
// 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 label = d.unread > 99 ? '99+' : d.unread;
|
||||
// update every badge (sidebar nav + homepage card) from the one source
|
||||
document.querySelectorAll('.notif-badge').forEach(b => {
|
||||
if (d.unread > 0) { b.textContent = label; 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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,18 +17,121 @@
|
||||
|
||||
.chat-log {
|
||||
flex: 1; overflow-y: auto; padding: 18px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Bubble row (holds avatar space + bubble + actions) ── */
|
||||
.msg-row {
|
||||
display: flex; align-items: flex-end; gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
.msg-row.out { flex-direction: row-reverse; }
|
||||
|
||||
/* ── Bubble ── */
|
||||
.bubble {
|
||||
max-width: 72%; padding: 8px 12px; border-radius: 14px;
|
||||
font-size: 14px; line-height: 1.4; word-wrap: break-word; white-space: pre-wrap;
|
||||
font-size: 14px; line-height: 1.4; word-wrap: break-word;
|
||||
position: relative;
|
||||
}
|
||||
.bubble .who { font-size: 11px; font-weight: 700; opacity: 0.7; margin-bottom: 2px; }
|
||||
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 3px; text-align: right; }
|
||||
.bubble .ts { font-size: 10px; opacity: 0.55; margin-top: 4px; text-align: right; }
|
||||
.bubble.in { align-self: flex-start; background: var(--bg); border: 1px solid var(--border); }
|
||||
.bubble.out { align-self: flex-end; background: var(--accent); color: var(--btn-light-text); }
|
||||
.bubble.deleted { font-style: italic; opacity: 0.5; }
|
||||
|
||||
/* ── Quote block (replied-to message) ── */
|
||||
.quote-block {
|
||||
border-left: 3px solid currentColor;
|
||||
padding: 4px 8px; margin-bottom: 6px; border-radius: 4px;
|
||||
font-size: 12px; opacity: 0.75;
|
||||
background: rgba(0,0,0,0.06);
|
||||
max-height: 60px; overflow: hidden;
|
||||
}
|
||||
.quote-block .q-who { font-weight: 700; margin-bottom: 1px; }
|
||||
.quote-block .q-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* ── Inline image ── */
|
||||
.msg-image {
|
||||
max-width: 100%; max-height: 300px; border-radius: 8px;
|
||||
display: block; margin-bottom: 4px; cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── File attachment ── */
|
||||
.file-block {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px; margin-bottom: 4px;
|
||||
background: rgba(0,0,0,0.08); border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-block .f-ico { font-size: 18px; flex-shrink: 0; }
|
||||
.file-block .f-meta { flex: 1; min-width: 0; }
|
||||
.file-block .f-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.file-block .f-size { opacity: 0.6; font-size: 11px; }
|
||||
.file-block .f-action button, .file-block .f-action a {
|
||||
font-size: 11px; padding: 3px 8px; border-radius: 6px;
|
||||
border: 1px solid currentColor; background: transparent; cursor: pointer;
|
||||
color: inherit; text-decoration: none; display: inline-block;
|
||||
}
|
||||
.file-block .f-action button:hover, .file-block .f-action a:hover { background: rgba(255,255,255,0.15); }
|
||||
|
||||
/* ── Reactions ── */
|
||||
.reactions {
|
||||
display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px;
|
||||
}
|
||||
.rxn {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
font-size: 13px; padding: 1px 6px;
|
||||
border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg); cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.rxn.me { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 15%, transparent); }
|
||||
.rxn:hover { background: var(--border); }
|
||||
.rxn .rxn-count { font-size: 11px; opacity: 0.8; }
|
||||
|
||||
/* ── Hover action buttons ── */
|
||||
.msg-actions {
|
||||
display: none; flex-direction: column; gap: 3px;
|
||||
align-self: center;
|
||||
}
|
||||
.msg-row:hover .msg-actions { display: flex; }
|
||||
.msg-act-btn {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 3px 7px; cursor: pointer;
|
||||
font-size: 12px; color: var(--text); white-space: nowrap;
|
||||
}
|
||||
.msg-act-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* ── Emoji picker strip ── */
|
||||
.emoji-strip {
|
||||
display: none; position: absolute; z-index: 10;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 5px 8px; gap: 4px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.msg-row:hover .emoji-strip { display: flex; }
|
||||
.msg-row.out .emoji-strip { right: 100%; margin-right: 6px; }
|
||||
.msg-row:not(.out) .emoji-strip { left: 100%; margin-left: 6px; }
|
||||
.e-btn { font-size: 18px; cursor: pointer; border: none; background: transparent;
|
||||
padding: 2px; border-radius: 4px; }
|
||||
.e-btn:hover { background: var(--bg); }
|
||||
|
||||
/* ── Reply preview above compose ── */
|
||||
.reply-preview {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 6px 12px; background: var(--bg); border-top: 1px solid var(--border);
|
||||
font-size: 12px; gap: 8px;
|
||||
}
|
||||
.reply-preview .rp-bar {
|
||||
width: 3px; align-self: stretch; border-radius: 2px;
|
||||
background: var(--accent); flex-shrink: 0;
|
||||
}
|
||||
.reply-preview .rp-body { flex: 1; min-width: 0; }
|
||||
.reply-preview .rp-who { font-weight: 700; color: var(--accent); }
|
||||
.reply-preview .rp-text { opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.reply-preview .rp-cancel { cursor: pointer; opacity: 0.5; font-size: 16px; flex-shrink: 0; }
|
||||
.reply-preview .rp-cancel:hover { opacity: 1; }
|
||||
|
||||
.chat-compose {
|
||||
display: flex; gap: 8px; padding: 12px; border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -51,11 +154,11 @@
|
||||
<div class="chat-head">
|
||||
<span class="title">{{ chat_name }}</span>
|
||||
<button class="btn btn-ghost" style="padding:4px 12px;font-size:12px;"
|
||||
onclick="loadMessages(true)">↻ Refresh</button>
|
||||
onclick="loadMessages(true)"><i class="fa-solid fa-rotate-right"></i> Refresh</button>
|
||||
</div>
|
||||
|
||||
{% if is_channel %}
|
||||
<div class="chat-banner">📢 Channel — messages you send here broadcast to all subscribers.</div>
|
||||
<div class="chat-banner"><i class="fa-solid fa-bullhorn"></i> Channel — messages you send here broadcast to all subscribers.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="chat-log" id="chat-log">
|
||||
@@ -66,6 +169,15 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="reply-preview" class="reply-preview" style="display:none;">
|
||||
<div class="rp-bar"></div>
|
||||
<div class="rp-body">
|
||||
<div class="rp-who" id="rp-who"></div>
|
||||
<div class="rp-text" id="rp-text"></div>
|
||||
</div>
|
||||
<span class="rp-cancel" onclick="cancelReply()">✕</span>
|
||||
</div>
|
||||
|
||||
<div class="chat-compose">
|
||||
<textarea id="msg-input" placeholder="{{ 'Broadcast a message…' if is_channel else 'Type a message…' }}"
|
||||
{% if not running %}disabled{% endif %}
|
||||
@@ -81,7 +193,16 @@ const CHAT_ID = {{ chat_id }};
|
||||
const RUNNING = {{ 'true' if running else 'false' }};
|
||||
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
|
||||
let lastIds = ''; // signature of rendered messages, to skip needless re-renders
|
||||
let lastIds = '';
|
||||
let replyTo = null; // {id, sender, text}
|
||||
|
||||
const QUICK_EMOJIS = ['👍','❤️','😂','😮','😢','🙏'];
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
||||
return (bytes/1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function fmtTs(iso) {
|
||||
if (!iso) return '';
|
||||
@@ -90,10 +211,75 @@ function fmtTs(iso) {
|
||||
return d.toLocaleString([], {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderQuote(q) {
|
||||
if (!q) return '';
|
||||
const who = q.sender ? `<div class="q-who">${escapeHtml(q.sender)}</div>` : '';
|
||||
const txt = q.text ? `<div class="q-text">${escapeHtml(q.text)}</div>` : '<div class="q-text"><em>attachment</em></div>';
|
||||
return `<div class="quote-block">${who}${txt}</div>`;
|
||||
}
|
||||
|
||||
function renderImage(m) {
|
||||
if (!m.image_preview) return '';
|
||||
// image_preview is a base64 data URI from the message content
|
||||
const escaped = escapeHtml(m.image_preview);
|
||||
// If file is downloaded, clicking opens the full-res version
|
||||
const fullUrl = m.file && m.file.status === 'rcvComplete'
|
||||
? `/api/profiles/${PROFILE_ID}/file/${m.file.id}/download`
|
||||
: null;
|
||||
const img = `<img class="msg-image" src="${escaped}" alt="image"
|
||||
${fullUrl ? `onclick="window.open('${fullUrl}','_blank')" title="Click to open full size"` : ''}>`;
|
||||
return img;
|
||||
}
|
||||
|
||||
function renderFile(f) {
|
||||
if (!f) return '';
|
||||
const isComplete = f.status === 'rcvComplete' || f.status === 'sndComplete' || f.status === 'sndStored';
|
||||
const ico = '<i class="fa-solid fa-paperclip"></i>';
|
||||
const name = escapeHtml(f.name || 'file');
|
||||
const size = fmtSize(f.size || 0);
|
||||
let action = '';
|
||||
if (f.status === 'rcvInvitation') {
|
||||
action = `<div class="f-action"><button onclick="acceptFile(${f.id},this)"><i class="fa-solid fa-download"></i> Accept</button></div>`;
|
||||
} else if (isComplete) {
|
||||
const dlUrl = `/api/profiles/${PROFILE_ID}/file/${f.id}/download`;
|
||||
action = `<div class="f-action"><a href="${dlUrl}" target="_blank"><i class="fa-solid fa-arrow-down"></i> Download</a></div>`;
|
||||
} else if (f.status && f.status.startsWith('rcvTransfer')) {
|
||||
action = `<div class="f-action"><span style="opacity:0.6;font-size:11px;"><i class="fa-solid fa-spinner fa-spin"></i> Downloading…</span></div>`;
|
||||
}
|
||||
return `<div class="file-block">
|
||||
<div class="f-ico">${ico}</div>
|
||||
<div class="f-meta">
|
||||
<div class="f-name" title="${name}">${name}</div>
|
||||
<div class="f-size">${size}</div>
|
||||
</div>
|
||||
${action}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderReactions(reactions, itemId) {
|
||||
if (!reactions || !reactions.length) return '';
|
||||
const pills = reactions.map(r => {
|
||||
const me = r.me ? ' me' : '';
|
||||
return `<span class="rxn${me}" title="${r.me?'You reacted':''}" onclick="toggleReaction(${itemId},'${escapeHtml(r.emoji)}',${r.me})">${escapeHtml(r.emoji)}<span class="rxn-count">${r.count}</span></span>`;
|
||||
}).join('');
|
||||
return `<div class="reactions">${pills}</div>`;
|
||||
}
|
||||
|
||||
function renderEmojiStrip(itemId) {
|
||||
const btns = QUICK_EMOJIS.map(e =>
|
||||
`<button class="e-btn" title="React ${e}" onclick="sendReaction(${itemId},'${e}',true,event)">${e}</button>`
|
||||
).join('');
|
||||
return `<div class="emoji-strip">${btns}</div>`;
|
||||
}
|
||||
|
||||
function render(messages) {
|
||||
const log = document.getElementById('chat-log');
|
||||
const sig = messages.map(m => m.id).join(',');
|
||||
if (sig === lastIds) return; // nothing new
|
||||
const sig = messages.map(m => m.id + ':' + (m.reactions||[]).map(r=>r.emoji+r.count).join('')).join(',');
|
||||
if (sig === lastIds) return;
|
||||
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
|
||||
lastIds = sig;
|
||||
|
||||
@@ -101,17 +287,35 @@ function render(messages) {
|
||||
log.innerHTML = '<div class="chat-empty">No messages yet.</div>';
|
||||
return;
|
||||
}
|
||||
log.innerHTML = messages.map(m => {
|
||||
const cls = 'bubble ' + (m.outgoing ? 'out' : 'in') + (m.deleted ? ' deleted' : '');
|
||||
const who = (!m.outgoing && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
|
||||
const txt = m.deleted ? '(deleted)' : escapeHtml(m.text || '');
|
||||
return `<div class="${cls}">${who}${txt}<div class="ts">${fmtTs(m.ts)}</div></div>`;
|
||||
}).join('');
|
||||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
log.innerHTML = messages.map(m => {
|
||||
const out = m.outgoing;
|
||||
const dir = out ? 'out' : 'in';
|
||||
const bubbleCls = `bubble ${dir}${m.deleted ? ' deleted' : ''}`;
|
||||
const who = (!out && m.sender) ? `<div class="who">${escapeHtml(m.sender)}</div>` : '';
|
||||
const txt = m.deleted ? '(deleted)' : (m.text ? escapeHtml(m.text) : '');
|
||||
|
||||
const quoteHtml = renderQuote(m.quote);
|
||||
const imageHtml = renderImage(m);
|
||||
const fileHtml = (!imageHtml || m.file) ? renderFile(m.file) : '';
|
||||
const reactHtml = renderReactions(m.reactions, m.id);
|
||||
const emojiStrip = m.deleted ? '' : renderEmojiStrip(m.id);
|
||||
const replyBtn = m.deleted ? '' :
|
||||
`<button class="msg-act-btn" onclick="setReply(${m.id},${JSON.stringify(escapeHtml(m.sender||'You'))},${JSON.stringify(escapeHtml((m.text||'').slice(0,80)))})" title="Reply"><i class="fa-solid fa-reply"></i></button>`;
|
||||
|
||||
const actionsHtml = `<div class="msg-actions">${replyBtn}</div>`;
|
||||
|
||||
const inner = `${quoteHtml}${imageHtml}${fileHtml}${who}${txt}${reactHtml}<div class="ts">${fmtTs(m.ts)}</div>`;
|
||||
|
||||
return `<div class="msg-row ${dir}" data-id="${m.id}">
|
||||
${emojiStrip}
|
||||
${out ? actionsHtml : ''}
|
||||
<div class="${bubbleCls}">${inner}</div>
|
||||
${!out ? actionsHtml : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
async function loadMessages(force) {
|
||||
@@ -127,28 +331,69 @@ async function loadMessages(force) {
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function setReply(id, sender, text) {
|
||||
replyTo = {id, sender, text};
|
||||
document.getElementById('rp-who').textContent = sender || 'Unknown';
|
||||
document.getElementById('rp-text').textContent = text || '(attachment)';
|
||||
document.getElementById('reply-preview').style.display = 'flex';
|
||||
document.getElementById('msg-input').focus();
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
replyTo = null;
|
||||
document.getElementById('reply-preview').style.display = 'none';
|
||||
}
|
||||
|
||||
async function sendMsg() {
|
||||
const input = document.getElementById('msg-input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
const body = {text};
|
||||
if (replyTo) body.reply_to_id = replyTo.id;
|
||||
const resp = await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/send`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
||||
body: JSON.stringify({text}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
cancelReply();
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
input.value = text; // restore on failure
|
||||
alert('Failed to send');
|
||||
input.value = text;
|
||||
alert('Failed to send: ' + (data.error || data.detail || 'unknown error'));
|
||||
return;
|
||||
}
|
||||
setTimeout(() => loadMessages(true), 250); // reflect the sent message quickly
|
||||
setTimeout(() => loadMessages(true), 250);
|
||||
}
|
||||
|
||||
async function sendReaction(itemId, emoji, add, ev) {
|
||||
if (ev) ev.stopPropagation();
|
||||
await fetch(`/api/profiles/${PROFILE_ID}/chat/${CHAT_TYPE}/${CHAT_ID}/react`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
||||
body: JSON.stringify({item_id: itemId, emoji, add}),
|
||||
});
|
||||
setTimeout(() => loadMessages(true), 300);
|
||||
}
|
||||
|
||||
async function toggleReaction(itemId, emoji, currently_reacted) {
|
||||
await sendReaction(itemId, emoji, !currently_reacted, null);
|
||||
}
|
||||
|
||||
async function acceptFile(fileId, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
const resp = await fetch(`/api/profiles/${PROFILE_ID}/file/${fileId}/receive`, {
|
||||
method: 'POST', headers: {'X-Token': _token()},
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) { btn.disabled = false; btn.textContent = 'Retry'; return; }
|
||||
setTimeout(() => loadMessages(true), 500);
|
||||
}
|
||||
|
||||
if (RUNNING) {
|
||||
loadMessages(true);
|
||||
setInterval(loadMessages, 3000); // live updates via polling
|
||||
setInterval(loadMessages, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
99
manager/templates/home.html
Normal file
99
manager/templates/home.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Home — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.home-head { margin-bottom: 28px; text-align: center; }
|
||||
.home-head h1 { margin-bottom: 6px; color: var(--accent); }
|
||||
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
|
||||
/* faded bar between areas, like the sidebar separators */
|
||||
.tiles + .tiles { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--border); }
|
||||
.tile {
|
||||
display: flex; flex-direction: row; align-items: center; gap: 12px;
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 16px 18px; text-decoration: none; color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
transition: border-color 0.15s, transform 0.05s;
|
||||
}
|
||||
.tile:hover { border-color: var(--accent); }
|
||||
.tile:active { transform: translateY(1px); }
|
||||
.tile .t-ico { font-size: 22px; line-height: 1; flex-shrink: 0; }
|
||||
.tile .t-title { font-size: 15px; font-weight: 700; }
|
||||
/* closing bar at the bottom */
|
||||
.home-end { margin-top: 28px; border-top: 1px solid var(--border); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="home-head">
|
||||
<h1>SimpleX Manager</h1>
|
||||
</div>
|
||||
|
||||
<!-- Area 1: your accounts -->
|
||||
<div class="tiles">
|
||||
<a class="tile" href="/users">
|
||||
<span class="t-ico"><i class="fa-solid fa-user"></i></span>
|
||||
<span class="t-title">Users</span>
|
||||
</a>
|
||||
<a class="tile" href="/bots">
|
||||
<span class="t-ico"><i class="fa-solid fa-robot"></i></span>
|
||||
<span class="t-title">Bots</span>
|
||||
</a>
|
||||
<a class="tile" href="/businesses">
|
||||
<span class="t-ico"><i class="fa-solid fa-briefcase"></i></span>
|
||||
<span class="t-title">Business Groups</span>
|
||||
</a>
|
||||
<a class="tile" href="https://simplex.chat/file/" target="_blank" rel="noopener">
|
||||
<span class="t-ico"><i class="fa-solid fa-upload"></i></span>
|
||||
<span class="t-title">File Upload</span>
|
||||
</a>
|
||||
<a class="tile" href="/rss-bots">
|
||||
<span class="t-ico"><i class="fa-solid fa-rss"></i></span>
|
||||
<span class="t-title">RSS</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Area: relays -->
|
||||
<div class="tiles">
|
||||
<a class="tile" href="/relays/chat">
|
||||
<span class="t-ico"><i class="fa-solid fa-comments"></i></span>
|
||||
<span class="t-title">Chat Relay</span>
|
||||
</a>
|
||||
<a class="tile" href="/relays/file">
|
||||
<span class="t-ico"><i class="fa-solid fa-file-export"></i></span>
|
||||
<span class="t-title">File Relay</span>
|
||||
</a>
|
||||
<a class="tile" href="/relays/message">
|
||||
<span class="t-ico"><i class="fa-solid fa-envelope"></i></span>
|
||||
<span class="t-title">Message Relay</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Area 3: system -->
|
||||
<div class="tiles">
|
||||
<a class="tile" href="/network">
|
||||
<span class="t-ico"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
<span class="t-title">Network</span>
|
||||
</a>
|
||||
<a class="tile" href="/notifications">
|
||||
<span class="t-ico"><i class="fa-solid fa-bell"></i></span>
|
||||
<span class="t-title">Notifications</span>
|
||||
<span class="notif-badge" style="display:none;"></span>
|
||||
</a>
|
||||
<a class="tile" href="/settings">
|
||||
<span class="t-ico"><i class="fa-solid fa-gear"></i></span>
|
||||
<span class="t-title">Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Area 3: SimpleX (external) -->
|
||||
<div class="tiles">
|
||||
<a class="tile" href="https://simplex.chat/downloads/" target="_blank" rel="noopener">
|
||||
<span class="t-ico"><i class="fa-solid fa-download"></i></span>
|
||||
<span class="t-title">Get SimpleX App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="home-end"></div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ tab | title }} — SimpleX Manager{% endblock %}
|
||||
{% import "_macros.html" as ui %}
|
||||
{% block title %}{{ 'Business Groups' if tab == 'businesses' else ('RSS' if tab == 'rss-bots' else tab | title) }} — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
@@ -19,22 +20,53 @@
|
||||
|
||||
.bot-types-card table td { vertical-align: top; }
|
||||
.bot-types-card .tag { white-space: nowrap; }
|
||||
|
||||
.chk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px 12px; }
|
||||
.chk { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 500;
|
||||
color: var(--text); cursor: pointer; }
|
||||
.chk input { width: auto; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set new_label = 'User' if tab == 'users' else ('Business Group' if tab == 'businesses' else ('RSS Bot' if tab == 'rss-bots' else 'Bot')) %}
|
||||
{% set page_title = 'Business Groups' if tab == 'businesses' else ('RSS' if tab == 'rss-bots' else tab | title) %}
|
||||
<div class="flex-between" style="margin-bottom: 24px;">
|
||||
<h1 style="margin:0;">{{ tab | title }}</h1>
|
||||
<h1 style="margin:0;">{{ page_title }}</h1>
|
||||
<button class="btn btn-primary" onclick="openCreate()">
|
||||
+ New {{ 'User' if tab == 'users' else 'Bot' }}
|
||||
+ New {{ new_label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if tab == 'businesses' %}
|
||||
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||
<h2 style="font-size:15px;margin-bottom:8px;">Business Groups</h2>
|
||||
<p class="muted" style="font-size:13px;">
|
||||
A business group uses a <strong>business address</strong>: each customer who connects gets
|
||||
their own group chat (so teammates can be added). You handle those conversations here, the same
|
||||
way you chat in a group. Set an optional welcome message to auto-greet new customers.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tab == 'rss-bots' %}
|
||||
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||
<h2 style="font-size:15px;margin-bottom:8px;">RSS</h2>
|
||||
<p class="muted" style="font-size:13px;">
|
||||
RSS bots read from an RSS/Atom feed and post new items to a channel. To receive a feed,
|
||||
share the bot's <strong>channel</strong> link with subscribers — open the bot and copy its
|
||||
<strong>channel</strong> link, <strong>not</strong> the user address.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tab == 'bots' %}
|
||||
<div class="card bot-types-card" style="margin-bottom:24px;">
|
||||
<h2 style="font-size:15px;margin-bottom:12px;">Available bot types</h2>
|
||||
<table>
|
||||
<tr><td><span class="tag">echo</span></td><td class="muted">Repeats every message back to the sender — handy for testing a connection end to end.</td></tr>
|
||||
<tr><td><span class="tag">llm</span></td><td class="muted">Chat with a local or remote LLM (OpenAI-compatible, e.g. Ollama). Give it context, it replies to your messages.</td></tr>
|
||||
<tr><td><span class="tag">crypto</span></td><td class="muted">Streams selected crypto prices (CoinGecko) to a channel on an interval. Pick coins & currencies below.</td></tr>
|
||||
<tr><td><span class="tag">broadcast</span></td><td class="muted">Relays messages from authorized publishers out to all of the bot's contacts.</td></tr>
|
||||
<tr><td><span class="tag">support</span></td><td class="muted">Business inbox — auto-replies with a welcome message and collects incoming inquiries.</td></tr>
|
||||
<tr><td><span class="tag">directory</span></td><td class="muted">Directory service for discovering and listing groups or contacts.</td></tr>
|
||||
@@ -49,6 +81,10 @@
|
||||
onclick="location.href='/profile/{{ p.id }}'">
|
||||
<div class="flex-between">
|
||||
<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>
|
||||
<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 %}"
|
||||
@@ -69,13 +105,15 @@
|
||||
onclick="this.textContent='Stopping…'">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if p.address %}
|
||||
<div class="addr-row" onclick="event.stopPropagation()">
|
||||
<button class="btn btn-ghost copy-btn" title="Copy address"
|
||||
onclick="copyAddr(event, this, '{{ p.address | e }}')">📋</button>
|
||||
<a class="addr-link" href="{{ p.address }}" target="_blank" rel="noopener">{{ p.address }}</a>
|
||||
{% if tab == 'rss-bots' %}
|
||||
<div style="margin-top:8px;font-size:12px;color:var(--muted);">
|
||||
<i class="fa-solid fa-clock"></i>
|
||||
<span id="poll-{{ p.id }}">{{ '—' if not p.running else 'Loading…' }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if p.address %}
|
||||
<div onclick="event.stopPropagation()" style="margin-top:10px;">{{ ui.linkbox(p.address, 'p' ~ p.id) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
@@ -83,6 +121,12 @@
|
||||
{% if tab == 'users' %}
|
||||
<strong>No users yet</strong>
|
||||
<p>Create a SimpleX user account to manage contacts and channels.</p>
|
||||
{% elif tab == 'businesses' %}
|
||||
<strong>No business groups yet</strong>
|
||||
<p>Create a business group; each customer who connects gets their own group chat.</p>
|
||||
{% elif tab == 'rss-bots' %}
|
||||
<strong>No RSS bots yet</strong>
|
||||
<p>Create an RSS bot to post a feed to a channel.</p>
|
||||
{% else %}
|
||||
<strong>No bots yet</strong>
|
||||
<p>Bots can echo messages, broadcast to subscribers, or run automated tasks.</p>
|
||||
@@ -92,12 +136,43 @@
|
||||
|
||||
<!-- Create dialog -->
|
||||
<dialog id="create-dialog">
|
||||
<h2 style="margin-bottom:20px;">New {{ 'User' if tab == 'users' else 'Bot' }}</h2>
|
||||
<h2 style="margin-bottom:20px;">New {{ new_label }}</h2>
|
||||
<form id="create-form">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else 'My Bot' }}" required>
|
||||
<input type="text" name="name" placeholder="{{ 'Alice' if tab == 'users' else ('Acme Inc' if tab == 'businesses' else 'My Bot') }}" required>
|
||||
</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 == 'businesses' %}
|
||||
<div class="field">
|
||||
<label>Welcome Message <span class="muted" style="font-weight:400;">(optional auto-reply to new customers)</span></label>
|
||||
<input type="text" name="welcome_message" placeholder="Thanks for reaching out! How can we help?">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tab == 'rss-bots' %}
|
||||
<div class="field">
|
||||
<label>Feed URL</label>
|
||||
<input type="text" name="feed_url" placeholder="https://example.com/feed.xml">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>How often to check the feed</label>
|
||||
<div class="chk-grid">
|
||||
<label class="chk"><input type="radio" name="rss_poll" value="3600" checked> Per hour</label>
|
||||
<label class="chk"><input type="radio" name="rss_poll" value="86400"> Per day</label>
|
||||
<label class="chk"><input type="radio" name="rss_poll" value="604800"> Per week</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tab == 'bots' %}
|
||||
<div class="field">
|
||||
<label>Bot Type</label>
|
||||
@@ -111,6 +186,120 @@
|
||||
<label>Welcome Message</label>
|
||||
<input type="text" name="welcome_message" placeholder="Welcome! How can I help?">
|
||||
</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 — works with a local Ollama via <code>ollama serve</code>,
|
||||
OpenAI, Grok…). The LLM bot needs the URL; support bots may leave it blank for a
|
||||
welcome-only inbox.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>API Base URL</label>
|
||||
<input type="text" name="api_base" placeholder="http://localhost:11434/v1 (Ollama) · https://api.x.ai/v1">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>API Key <span class="muted" style="font-weight:400;">(any value for Ollama)</span></label>
|
||||
<input type="password" name="api_key" placeholder="ollama · xai-…">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Model</label>
|
||||
<input type="text" name="model" placeholder="llama3.2 (Ollama) · grok-2">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Context <span class="muted" style="font-weight:400;">(system prompt given on start-up)</span></label>
|
||||
<textarea name="system_prompt" rows="3" placeholder="You are a helpful 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>
|
||||
<div id="broadcast-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;">
|
||||
Only listed publishers can broadcast; their text/links are relayed to every contact.
|
||||
Anyone else gets the prohibited reply and their message is deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Publishers <span class="muted" style="font-weight:400;">(comma-separated; "Name" or "ID:Name")</span></label>
|
||||
<input type="text" name="publishers" placeholder="Alice, 2:Bob">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Prohibited reply <span class="muted" style="font-weight:400;">(blank = default listing publishers)</span></label>
|
||||
<input type="text" name="prohibited_message" placeholder="Only publishers can broadcast. Your message is deleted.">
|
||||
</div>
|
||||
</div>
|
||||
<div id="crypto-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;">
|
||||
Posts a price snapshot of the selected coins to a channel every interval (via CoinGecko).
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Coins</label>
|
||||
<div class="chk-grid">
|
||||
<label class="chk"><input type="checkbox" name="coin" value="bitcoin" checked> Bitcoin</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="ethereum" checked> Ethereum</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="solana"> Solana</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="ripple"> XRP</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="cardano"> Cardano</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="dogecoin"> Dogecoin</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="binancecoin"> BNB</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="polkadot"> Polkadot</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="litecoin"> Litecoin</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="tron"> TRON</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="chainlink"> Chainlink</label>
|
||||
<label class="chk"><input type="checkbox" name="coin" value="tether"> Tether</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Currencies</label>
|
||||
<div class="chk-grid">
|
||||
<label class="chk"><input type="checkbox" name="cur" value="usd" checked> USD</label>
|
||||
<label class="chk"><input type="checkbox" name="cur" value="gbp" checked> GBP</label>
|
||||
<label class="chk"><input type="checkbox" name="cur" value="eur"> EUR</label>
|
||||
<label class="chk"><input type="checkbox" name="cur" value="jpy"> JPY</label>
|
||||
<label class="chk"><input type="checkbox" name="cur" value="aud"> AUD</label>
|
||||
<label class="chk"><input type="checkbox" name="cur" value="cad"> CAD</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Poll interval <span class="muted" style="font-weight:400;">(seconds)</span></label>
|
||||
<input type="number" name="crypto_poll_seconds" min="60" value="300">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-8 mt-16" style="justify-content:flex-end;">
|
||||
<button type="button" class="btn btn-ghost"
|
||||
@@ -121,35 +310,93 @@
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const _pollNextMap = {}; // profile_id → poll_next epoch (seconds)
|
||||
|
||||
function fmtCountdown(secs) {
|
||||
if (secs <= 0) return 'polling now…';
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
if (h > 0) return `in ${h}h ${m}m`;
|
||||
if (m > 0) return `in ${m}m ${s}s`;
|
||||
return `in ${s}s`;
|
||||
}
|
||||
|
||||
function tickPolls() {
|
||||
const now = Date.now() / 1000;
|
||||
for (const [id, pollNext] of Object.entries(_pollNextMap)) {
|
||||
const el = document.getElementById('poll-' + id);
|
||||
if (!el) continue;
|
||||
el.textContent = pollNext > 0 ? fmtCountdown(pollNext - now) : '—';
|
||||
}
|
||||
}
|
||||
setInterval(tickPolls, 1000);
|
||||
|
||||
function updateStatus(id, event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.responseText);
|
||||
const badge = document.getElementById('status-' + id);
|
||||
if (!badge) return;
|
||||
if (badge) {
|
||||
badge.textContent = data.running ? 'running' : 'stopped';
|
||||
badge.className = 'badge ' + (data.running ? 'badge-green' : 'badge-red');
|
||||
}
|
||||
if (data.poll_next !== undefined) {
|
||||
_pollNextMap[id] = data.running ? data.poll_next : 0;
|
||||
const el = document.getElementById('poll-' + id);
|
||||
if (el) el.textContent = data.running && data.poll_next > 0
|
||||
? fmtCountdown(data.poll_next - Date.now() / 1000) : '—';
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
let avatarDataUri = '';
|
||||
|
||||
function openCreate() {
|
||||
document.getElementById('create-form').reset();
|
||||
avatarDataUri = '';
|
||||
const prev = document.getElementById('avatar-preview');
|
||||
prev.style.display = 'none'; prev.src = '';
|
||||
{% if tab == 'bots' %}onTypeChange();{% endif %}
|
||||
document.getElementById('create-dialog').showModal();
|
||||
}
|
||||
|
||||
function copyAddr(ev, btn, addr) {
|
||||
ev.stopPropagation();
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = '📋', 1500);
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
// robustCopy and the SimpleX link box (sx*) live in base.html (shared).
|
||||
|
||||
{% if tab == 'bots' %}
|
||||
function onTypeChange() {
|
||||
const val = document.getElementById('type-select').value;
|
||||
const hide = ['echo'].includes(val); // echo has no welcome msg
|
||||
document.getElementById('welcome-field').style.display = hide ? 'none' : '';
|
||||
document.getElementById('welcome-field').style.display = (val === 'echo') ? 'none' : '';
|
||||
document.getElementById('support-fields').style.display = (val === 'support' || val === 'llm') ? 'block' : 'none';
|
||||
document.getElementById('deadmans-fields').style.display = (val === 'deadmans') ? 'block' : 'none';
|
||||
document.getElementById('directory-fields').style.display = (val === 'directory') ? 'block' : 'none';
|
||||
document.getElementById('broadcast-fields').style.display = (val === 'broadcast') ? 'block' : 'none';
|
||||
document.getElementById('crypto-fields').style.display = (val === 'crypto') ? 'block' : 'none';
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
@@ -164,7 +411,58 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
|
||||
const config = {};
|
||||
const welcome = fd.get('welcome_message');
|
||||
if (welcome) config.welcome_message = welcome;
|
||||
if (botType === 'support' || botType === 'llm') {
|
||||
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;
|
||||
}
|
||||
if (botType === 'broadcast') {
|
||||
const pubs = (fd.get('publishers') || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (pubs.length) config.publishers = pubs;
|
||||
const prohibited = (fd.get('prohibited_message') || '').trim();
|
||||
if (prohibited) config.prohibited_message = prohibited;
|
||||
}
|
||||
if (botType === 'rss') {
|
||||
const url = (fd.get('feed_url') || '').trim();
|
||||
if (!url) { alert('Feed URL is required for an RSS bot'); return; }
|
||||
config.feed_url = url;
|
||||
const ps = parseInt(fd.get('rss_poll'), 10);
|
||||
if (!isNaN(ps)) config.poll_seconds = ps;
|
||||
}
|
||||
if (botType === 'crypto') {
|
||||
const coins = Array.from(document.querySelectorAll('#crypto-fields input[name=coin]:checked')).map(c => c.value);
|
||||
const curs = Array.from(document.querySelectorAll('#crypto-fields input[name=cur]:checked')).map(c => c.value);
|
||||
if (!coins.length) { alert('Pick at least one coin'); return; }
|
||||
if (!curs.length) { alert('Pick at least one currency'); return; }
|
||||
config.coins = coins;
|
||||
config.currencies = curs;
|
||||
const ps = parseInt(fd.get('crypto_poll_seconds'), 10);
|
||||
if (!isNaN(ps) && ps >= 60) config.poll_seconds = ps;
|
||||
}
|
||||
{% 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 resp = await fetch('/api/profiles', {
|
||||
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 %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "_macros.html" as ui %}
|
||||
{% block title %}{{ profile.name }} — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
@@ -6,8 +7,6 @@
|
||||
.qr-wrap { text-align: center; padding: 16px; }
|
||||
.qr-wrap canvas { border-radius: 8px; }
|
||||
|
||||
.row-action { opacity: 0; transition: opacity 0.15s; }
|
||||
tr:hover .row-action { opacity: 1; }
|
||||
|
||||
.msg-btn {
|
||||
padding: 3px 10px; font-size: 12px; border-radius: 6px;
|
||||
@@ -17,6 +16,8 @@
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.msg-btn:hover { background: var(--accent); color: var(--btn-light-text); }
|
||||
.msg-btn-danger { color: var(--red); border-color: var(--red); }
|
||||
.msg-btn-danger:hover { background: var(--red); color: #fff; }
|
||||
|
||||
.addr-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.addr-link { flex: 1; min-width: 0; color: var(--muted); font-family: monospace; font-size: 12px;
|
||||
@@ -29,7 +30,7 @@
|
||||
{% block content %}
|
||||
<div class="flex-between" style="margin-bottom: 20px;">
|
||||
<div class="flex gap-8">
|
||||
<a href="{{ back }}" class="muted" style="text-decoration:none;">← {{ 'Users' if back == '/users' else 'Bots' }}</a>
|
||||
<a href="{{ back }}" class="muted" style="text-decoration:none;">← {{ 'Users' if back == '/users' else ('Business Groups' if back == '/businesses' else 'Bots') }}</a>
|
||||
<span class="muted">/</span>
|
||||
<strong>{{ profile.name }}</strong>
|
||||
<span class="tag {% if profile.bot_type == 'user' %}tag-user{% endif %}">{{ profile.bot_type }}</span>
|
||||
@@ -56,37 +57,67 @@
|
||||
<div class="grid-2">
|
||||
<!-- Left column -->
|
||||
<div>
|
||||
<!-- Address / QR -->
|
||||
<!-- Profile -->
|
||||
<div class="card">
|
||||
<h2>Address</h2>
|
||||
<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 -->
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom:12px;">Address</h2>
|
||||
{% if profile.address %}
|
||||
<div class="addr-row">
|
||||
<button class="btn btn-ghost copy-btn" title="Copy address"
|
||||
onclick="copyAddr(this, '{{ profile.address | e }}')">📋</button>
|
||||
<a class="addr-link" href="{{ profile.address }}" target="_blank" rel="noopener" id="address-text">{{ profile.address }}</a>
|
||||
</div>
|
||||
<div class="qr-wrap">
|
||||
<canvas id="qr-canvas"></canvas>
|
||||
<p class="muted" style="margin-top:10px;">Scan QR code from mobile app to start a chat</p>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
|
||||
<script>
|
||||
QRCode.toCanvas(document.getElementById('qr-canvas'), {{ profile.address | tojson }}, {width: 200}, () => {})
|
||||
</script>
|
||||
{{ ui.linkbox(profile.address, 'addr') }}
|
||||
{% else %}
|
||||
<p class="muted">Start the profile to generate an address.</p>
|
||||
{% endif %}
|
||||
</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')"><i class="fa-solid fa-copy"></i></button>
|
||||
<a class="addr-link" href="/directory/{{ safe }}/index.html" target="_blank" rel="noopener">/directory/{{ safe }}/index.html</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Config -->
|
||||
<div class="card">
|
||||
<h2>Config</h2>
|
||||
<table>
|
||||
<tr><th>Key</th><th>Value</th></tr>
|
||||
{% for k, v in profile.config.items() %}
|
||||
<tr><td>{{ k }}</td><td>{{ v }}</td></tr>
|
||||
{% for k, v in profile.config.items() if k not in ['avatar', 'bio', 'full_name'] %}
|
||||
<tr><td>{{ k }}</td><td>{% if k == 'api_key' %}•••••••• (set){% else %}{{ v }}{% endif %}</td></tr>
|
||||
{% 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 %}
|
||||
</table>
|
||||
</div>
|
||||
@@ -104,8 +135,14 @@
|
||||
<tr>
|
||||
<td><strong>{{ c.localDisplayName }}</strong></td>
|
||||
<td>
|
||||
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||
href="/profile/{{ profile.id }}/chat/direct/{{ c.contactId }}">💬 Chat</a>
|
||||
<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 }}"><i class="fa-solid fa-comments"></i> Chat</a>
|
||||
<button class="msg-btn" title="Clear conversation"
|
||||
onclick="clearChat('direct', {{ c.contactId }}, '{{ c.localDisplayName | e }}')"><i class="fa-solid fa-broom"></i> Clear</button>
|
||||
<button class="msg-btn msg-btn-danger" title="Delete contact"
|
||||
onclick="deleteContact({{ c.contactId }}, '{{ c.localDisplayName | e }}')"><i class="fa-solid fa-trash"></i> Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -122,20 +159,37 @@
|
||||
{% set name = g.groupProfile.displayName %}
|
||||
{% set gid = g.groupId %}
|
||||
{% set mcnt = g.groupSummary.currentMembers %}
|
||||
{% set invited = (g.membership.memberStatus if g.membership else '') == 'invited' %}
|
||||
{% set is_owner = (g.membership.memberRole if g.membership else '') == 'owner' %}
|
||||
<tr>
|
||||
<td>{{ name }}</td>
|
||||
<td>
|
||||
{% if invited %}
|
||||
<span class="tag" title="You were invited but haven't joined yet"><i class="fa-solid fa-hourglass-half"></i> 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;"
|
||||
onclick="loadMembers({{ gid }}, '{{ name | e }}')">{{ mcnt }}</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-8">
|
||||
<a class="msg-btn row-action" style="text-decoration:none;"
|
||||
href="/profile/{{ profile.id }}/chat/group/{{ gid }}">💬 {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
|
||||
<button class="msg-btn row-action" onclick="getGroupLink({{ gid }}, this)">Link</button>
|
||||
<div class="flex gap-8" style="flex-wrap:wrap;">
|
||||
{% 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 }}"><i class="fa-solid fa-comments"></i> {{ 'Broadcast' if g.is_channel else 'Chat' }}</a>
|
||||
{% if g.link %}{{ ui.linkbtns('g' ~ gid) }}{% endif %}
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{% if g.link %}
|
||||
<tr><td colspan="3" style="border:none;padding:0 12px 4px;">{{ ui.linkpanels(g.link, 'g' ~ gid) }}</td></tr>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Groups -->
|
||||
@@ -220,13 +274,42 @@
|
||||
<div class="flex-between" style="margin-bottom:16px;">
|
||||
<h2 style="margin:0;">Members — <span id="members-channel-name" style="color:var(--accent);"></span></h2>
|
||||
<button class="btn btn-ghost" style="padding:4px 10px;font-size:13px;"
|
||||
onclick="document.getElementById('members-dialog').close()">✕</button>
|
||||
onclick="document.getElementById('members-dialog').close()"><i class="fa-solid fa-xmark"></i></button>
|
||||
</div>
|
||||
<div id="members-list" style="max-height:320px;overflow-y:auto;">
|
||||
<p class="muted">Loading…</p>
|
||||
</div>
|
||||
</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 -->
|
||||
<dialog id="msg-dialog">
|
||||
<h2 style="margin-bottom:16px;">Message <span id="msg-target-label" style="color:var(--accent);"></span></h2>
|
||||
@@ -278,13 +361,96 @@ async function sendMsg() {
|
||||
}
|
||||
}
|
||||
|
||||
// robustCopy/fallbackCopy live in base.html (shared). Directory-website URL copy:
|
||||
function copyAddr(btn, addr) {
|
||||
navigator.clipboard.writeText(addr).then(() => {
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => btn.textContent = '📋', 1500);
|
||||
robustCopy(addr).then(() => {
|
||||
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
||||
setTimeout(() => btn.innerHTML = '<i class="fa-solid fa-copy"></i>', 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Contact actions ────────────────────────────────────────────────────────
|
||||
function _ctoken() { return document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''; }
|
||||
|
||||
async function clearChat(type, id, name) {
|
||||
if (!confirm('Clear the conversation with ' + name + '? Messages are removed; the contact stays.')) return;
|
||||
const r = await fetch(`/api/profiles/{{ profile.id }}/chat/${type}/${id}/clear`, {
|
||||
method: 'POST', headers: { 'X-Token': _ctoken() },
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) { alert('Conversation cleared.'); }
|
||||
else { alert('Failed: ' + (d.detail || 'unknown')); }
|
||||
}
|
||||
|
||||
async function deleteContact(id, name) {
|
||||
if (!confirm('Delete contact ' + name + '? This removes them and your conversation.')) return;
|
||||
const r = await fetch(`/api/profiles/{{ profile.id }}/contacts/${id}`, {
|
||||
method: 'DELETE', headers: { 'X-Token': _ctoken() },
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) { location.reload(); }
|
||||
else { alert('Failed: ' + (d.detail || 'unknown')); }
|
||||
}
|
||||
|
||||
// ── Edit profile ───────────────────────────────────────────────────────────
|
||||
// editAvatar: null = unchanged, '' = remove, dataURI = replace
|
||||
let editAvatar = null;
|
||||
|
||||
function openEdit() {
|
||||
editAvatar = null;
|
||||
document.getElementById('edit-fullname').value = {{ (profile.config.full_name or '') | tojson }};
|
||||
document.getElementById('edit-bio').value = {{ (profile.config.bio or '') | tojson }};
|
||||
const prev = document.getElementById('edit-avatar-preview');
|
||||
const cur = {{ (profile.config.avatar or '') | tojson }};
|
||||
if (cur) { prev.src = cur; prev.style.display = 'block'; } else { prev.style.display = 'none'; prev.src = ''; }
|
||||
document.getElementById('edit-result').textContent = '';
|
||||
document.getElementById('edit-dialog').showModal();
|
||||
}
|
||||
|
||||
function onEditAvatar(input) {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const size = 256, c = document.createElement('canvas');
|
||||
c.width = size; c.height = size;
|
||||
const ctx = c.getContext('2d');
|
||||
const m = Math.min(img.width, img.height);
|
||||
ctx.drawImage(img, (img.width - m) / 2, (img.height - m) / 2, m, m, 0, 0, size, size);
|
||||
editAvatar = c.toDataURL('image/jpeg', 0.85);
|
||||
const prev = document.getElementById('edit-avatar-preview');
|
||||
prev.src = editAvatar; prev.style.display = 'block';
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removeEditAvatar() {
|
||||
editAvatar = '';
|
||||
const prev = document.getElementById('edit-avatar-preview');
|
||||
prev.style.display = 'none'; prev.src = '';
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
const body = {
|
||||
full_name: document.getElementById('edit-fullname').value,
|
||||
bio: document.getElementById('edit-bio').value,
|
||||
};
|
||||
if (editAvatar !== null) body.avatar = editAvatar; // only send if changed
|
||||
document.getElementById('edit-result').textContent = 'Saving…';
|
||||
const resp = await fetch('/api/profiles/{{ profile.id }}/profile', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-Token': _token()},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.ok) { location.reload(); }
|
||||
else { document.getElementById('edit-result').textContent = '✗ ' + (data.detail || 'Failed'); }
|
||||
}
|
||||
|
||||
// ── Groups & Channels ──────────────────────────────────────────────────────
|
||||
const _token = () => document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || '';
|
||||
let _createKind = 'group';
|
||||
@@ -331,7 +497,7 @@ async function createGroup() {
|
||||
|
||||
function copyChLink() {
|
||||
const val = document.getElementById('ch-link-out').value;
|
||||
navigator.clipboard.writeText(val).then(() => {
|
||||
robustCopy(val).then(() => {
|
||||
document.getElementById('ch-result').textContent = '✓ Copied';
|
||||
});
|
||||
}
|
||||
@@ -367,21 +533,39 @@ async function loadMembers(groupId, groupName) {
|
||||
</table>`;
|
||||
}
|
||||
|
||||
async function getGroupLink(groupId, btn) {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '…';
|
||||
const resp = await fetch(`/api/profiles/{{ profile.id }}/groups/${groupId}/link`, {
|
||||
headers: {'X-Token': _token()},
|
||||
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.link) {
|
||||
await navigator.clipboard.writeText(data.link);
|
||||
btn.textContent = '✓ Copied';
|
||||
} else {
|
||||
btn.textContent = 'No link';
|
||||
}
|
||||
setTimeout(() => btn.textContent = orig, 2000);
|
||||
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')); }
|
||||
}
|
||||
|
||||
// Group/channel links use the shared link box (sxToggleLink/sxToggleQr in base.html).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function refreshLog(event) {
|
||||
|
||||
15
manager/templates/relay.html
Normal file
15
manager/templates/relay.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }} — SimpleX Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex-between" style="margin-bottom:24px;">
|
||||
<h1 style="margin:0;">{{ title }}</h1>
|
||||
<a href="/" class="muted" style="text-decoration:none;">← Home</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="text-align:center;padding:48px 24px;">
|
||||
<div style="font-size:40px;line-height:1;margin-bottom:12px;"><i class="fa-solid fa-shuffle"></i></div>
|
||||
<strong>{{ title }} — coming soon</strong>
|
||||
<p class="muted" style="margin-top:8px;">This relay isn’t implemented yet.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Original Light</span>
|
||||
<span class="checkmark">✓</span>
|
||||
<span class="checkmark"><i class="fa-solid fa-check"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Original Dark</span>
|
||||
<span class="checkmark">✓</span>
|
||||
<span class="checkmark"><i class="fa-solid fa-check"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,13 +126,28 @@
|
||||
</div>
|
||||
<div class="theme-label">
|
||||
<span>Matrix</span>
|
||||
<span class="checkmark">✓</span>
|
||||
<span class="checkmark"><i class="fa-solid fa-check"></i></span>
|
||||
</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>
|
||||
function currentTheme() {
|
||||
return localStorage.getItem('theme') ||
|
||||
|
||||
BIN
web/SC-QR.png
Normal file
BIN
web/SC-QR.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
348
web/about.html
Normal file
348
web/about.html
Normal file
@@ -0,0 +1,348 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>About — Speakers' Corner Online Directory</title>
|
||||
<meta name="description" content="Why Speakers' Corner Online exists — private, uncensored conversation for UK residents facing the Online Safety Act, digital ID, and growing surveillance of speech.">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1d1d1f;
|
||||
--muted: #6e6e73;
|
||||
--accent: #0053D0;
|
||||
--border: #e0e0e5;
|
||||
--shadow: 0px 20px 30px rgba(0,0,0,0.12);
|
||||
--warn: #b91c1c;
|
||||
--warn-bg: #fef2f2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--card-bg: #0B2A59;
|
||||
--text: #f5f5f7;
|
||||
--muted: #9ca3af;
|
||||
--accent: #70F0F9;
|
||||
--border: #1e3a5f;
|
||||
--shadow: none;
|
||||
--warn: #fca5a5;
|
||||
--warn-bg: rgba(185,28,28,0.18);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--card-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 14px 24px;
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 860px; margin: 0 auto;
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 10px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px; font-weight: 700;
|
||||
color: var(--accent); letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 760px; margin: 0 auto;
|
||||
padding: 48px 20px 80px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(26px, 5vw, 36px);
|
||||
font-weight: 700; color: var(--accent);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
font-size: 16px; color: var(--muted);
|
||||
line-height: 1.7; margin-bottom: 40px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.page-lead a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
/* Stat banner */
|
||||
.stat-banner {
|
||||
background: var(--warn-bg);
|
||||
border: 1.5px solid var(--warn);
|
||||
border-radius: 14px;
|
||||
padding: 22px 28px;
|
||||
margin-bottom: 32px;
|
||||
display: flex; gap: 18px; align-items: flex-start;
|
||||
}
|
||||
.stat-number {
|
||||
font-size: clamp(36px, 8vw, 52px);
|
||||
font-weight: 800; color: var(--warn);
|
||||
line-height: 1; flex-shrink: 0;
|
||||
}
|
||||
.stat-body { flex: 1; }
|
||||
.stat-body strong {
|
||||
display: block; font-size: 15px;
|
||||
color: var(--text); margin-bottom: 5px;
|
||||
}
|
||||
.stat-body p { font-size: 13px; color: var(--muted); line-height: 1.6; }
|
||||
.stat-body a { color: var(--warn); text-decoration: none; font-size: 12px; }
|
||||
.stat-body a:hover { text-decoration: underline; }
|
||||
|
||||
/* Section cards */
|
||||
.section {
|
||||
background: var(--card-bg); border-radius: 16px;
|
||||
box-shadow: var(--shadow); padding: 28px 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-icon { font-size: 26px; margin-bottom: 10px; display: block; }
|
||||
|
||||
.section h2 { font-size: 19px; font-weight: 700; margin-bottom: 14px; color: var(--text); }
|
||||
|
||||
.section p {
|
||||
font-size: 14px; line-height: 1.75; color: var(--muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section p:last-child { margin-bottom: 0; }
|
||||
.section a { color: var(--accent); text-decoration: none; }
|
||||
.section a:hover { text-decoration: underline; }
|
||||
|
||||
.section ol, .section ul { padding-left: 20px; margin-top: 8px; }
|
||||
.section li { font-size: 14px; line-height: 1.7; color: var(--muted); margin-bottom: 6px; }
|
||||
.section li:last-child { margin-bottom: 0; }
|
||||
|
||||
/* Rule rows */
|
||||
.rule-list { list-style: none; padding: 0; margin-top: 8px; }
|
||||
.rule-list li {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.rule-list li:last-child { border-bottom: none; }
|
||||
.rule-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent); flex-shrink: 0; margin-top: 6px;
|
||||
}
|
||||
.rule-body strong { display: block; font-size: 14px; color: var(--text); margin-bottom: 2px; }
|
||||
.rule-body span { font-size: 13px; color: var(--muted); line-height: 1.55; }
|
||||
|
||||
/* Callouts */
|
||||
.callout {
|
||||
border-left: 4px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border-radius: 0 10px 10px 0;
|
||||
padding: 12px 16px; margin-top: 14px;
|
||||
font-size: 13px; color: var(--muted); line-height: 1.65;
|
||||
}
|
||||
.callout.warn {
|
||||
border-left-color: var(--warn);
|
||||
background: var(--warn-bg);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center; padding: 28px 20px;
|
||||
font-size: 12px; color: var(--muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
footer a { color: var(--accent); text-decoration: none; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.section { padding: 20px; }
|
||||
.stat-banner { flex-direction: column; gap: 8px; padding: 18px 20px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<a href="./index.html" style="text-decoration:none;">
|
||||
<span class="logo-text">Speakers' Corner Online</span>
|
||||
</a>
|
||||
<a href="./index.html" style="color:var(--accent);font-size:13px;font-weight:600;text-decoration:none;">← Directory</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<h1>Why This Exists</h1>
|
||||
<p class="page-lead">
|
||||
The United Kingdom has quietly become one of the most aggressive surveilleurs of online
|
||||
speech in the democratic world. Speakers' Corner Online was built as a response — a place
|
||||
where residents can talk freely, privately, and without fear, using infrastructure that
|
||||
cannot be monitored or compelled to hand over your conversations.
|
||||
</p>
|
||||
|
||||
<!-- Stat banner -->
|
||||
<div class="stat-banner">
|
||||
<div class="stat-number">65,000+</div>
|
||||
<div class="stat-body">
|
||||
<strong>arrests in the UK for social media posts since 2017</strong>
|
||||
<p>
|
||||
British police forces have made over 65,000 arrests for online speech in less than a decade —
|
||||
averaging more than 20 arrests every single day. Offences range from "grossly offensive"
|
||||
messages under the Communications Act to alleged "stirring up" of hatred, applied
|
||||
increasingly broadly and with little consistency.
|
||||
</p>
|
||||
<a href="https://archive.ph/bdEqK" target="_blank">Source: archived report ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Online Safety Act -->
|
||||
<div class="section">
|
||||
<span class="section-icon">🔒</span>
|
||||
<h2>The Online Safety Act</h2>
|
||||
<p>
|
||||
The Online Safety Act 2023 granted Ofcom sweeping powers to demand that platforms
|
||||
scan private messages for illegal content — a power that, by technical necessity,
|
||||
requires breaking end-to-end encryption. Put simply: if a platform must be able to
|
||||
read your messages to check them, your messages are no longer private.
|
||||
</p>
|
||||
<p>
|
||||
The Act also imposes broad "duty of care" obligations that incentivise platforms to
|
||||
over-censor legal speech to avoid regulatory liability. The practical result is that
|
||||
mainstream platforms increasingly remove or restrict content that is perfectly lawful —
|
||||
not because it breaks any law, but because it is cheaper than arguing with a regulator.
|
||||
</p>
|
||||
<div class="callout warn">
|
||||
Signal, WhatsApp and others have threatened to leave the UK market rather than comply
|
||||
with backdoor requirements. SimpleX's architecture makes compliance technically
|
||||
impossible — there is no central server holding your keys and no company that can be
|
||||
served a disclosure order for your messages.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Digital ID -->
|
||||
<div class="section">
|
||||
<span class="section-icon">🍌</span>
|
||||
<h2>Digital Identity & the Coming Infrastructure</h2>
|
||||
<p>
|
||||
The UK government is rolling out a voluntary Digital Identity and Attributes Trust
|
||||
Framework that creates the infrastructure for verified digital IDs accepted across
|
||||
government and private services. "Voluntary" has a habit of becoming mandatory once
|
||||
the infrastructure exists — access to banking, benefits, travel and eventually online
|
||||
platforms may increasingly depend on presenting a verified digital identity.
|
||||
</p>
|
||||
<p>
|
||||
Combined with age-verification mandates in the Online Safety Act, the direction of
|
||||
travel is clear: anonymous online participation is being engineered out of existence.
|
||||
Once every account is tied to a real identity, the chilling effect on speech becomes
|
||||
total — people will self-censor on anything that could attract official attention.
|
||||
</p>
|
||||
<div class="callout">
|
||||
SimpleX requires no phone number, no email address, and no government ID.
|
||||
Your identity on this network is a cryptographic key that exists only on your device.
|
||||
There is nothing to subpoena and no account to suspend.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Why SimpleX -->
|
||||
<div class="section">
|
||||
<span class="section-icon">🚀</span>
|
||||
<h2>Why SimpleX</h2>
|
||||
<p>
|
||||
Most "private" messaging apps still require a phone number — which ties every account
|
||||
to a real identity via your mobile carrier. SimpleX was designed from the ground up to
|
||||
have no user identifiers at all. Each conversation uses a fresh pair of message queues;
|
||||
even the platform operator cannot link two conversations to the same person.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>No phone number or email required</strong> — connect via a QR code or link.</li>
|
||||
<li><strong>No central user database</strong> — there is nothing to leak, sell, or hand to police.</li>
|
||||
<li><strong>End-to-end encrypted</strong> — messages are decrypted only on your device.</li>
|
||||
<li><strong>Open source</strong> — the code can be audited by anyone.</li>
|
||||
<li><strong>Self-hostable</strong> — you can run your own relay servers and remain fully independent.</li>
|
||||
</ul>
|
||||
<p style="margin-top:12px;">
|
||||
<a href="https://simplex.chat/downloads/" target="_blank">Download the SimpleX app →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Community rules -->
|
||||
<div class="section">
|
||||
<span class="section-icon">📜</span>
|
||||
<h2>Community Rules</h2>
|
||||
<p>
|
||||
We defend the right to hold and express unpopular opinions. The narrow set of rules
|
||||
below are about direct harm — not offence, not controversy, not dissent.
|
||||
</p>
|
||||
<ul class="rule-list">
|
||||
<li>
|
||||
<div class="rule-dot"></div>
|
||||
<div class="rule-body">
|
||||
<strong>No illegal content</strong>
|
||||
<span>Content that is illegal in the UK — CSAM, credible incitement to imminent violence, etc. — will result in immediate removal and referral to authorities.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="rule-dot"></div>
|
||||
<div class="rule-body">
|
||||
<strong>No targeted harassment campaigns</strong>
|
||||
<span>Groups whose sole purpose is to coordinate abuse toward a specific individual are not permitted.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="rule-dot"></div>
|
||||
<div class="rule-body">
|
||||
<strong>No doxing</strong>
|
||||
<span>Publishing someone's private personal information (home address, workplace, phone number) without consent is not allowed.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="rule-dot"></div>
|
||||
<div class="rule-body">
|
||||
<strong>Label adult content</strong>
|
||||
<span>Communities containing explicit material must say so clearly in their description.</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="rule-dot"></div>
|
||||
<div class="rule-body">
|
||||
<strong>Honest descriptions</strong>
|
||||
<span>Your group's name and description must accurately represent what it is. No spam, no bait-and-switch.</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="callout">
|
||||
Controversial, offensive, politically extreme, or unpopular speech is explicitly
|
||||
permitted here. The entire point of this platform is that you do not need our approval
|
||||
to speak — only your own conscience.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reporting -->
|
||||
<div class="section">
|
||||
<span class="section-icon">📢</span>
|
||||
<h2>Reporting & Joining</h2>
|
||||
<p>
|
||||
To report a listed community that breaches the rules above, connect to the directory bot
|
||||
via the QR code on the <a href="./index.html">main page</a> and send a brief message
|
||||
with the community name and the nature of the breach. Reports are reviewed manually.
|
||||
</p>
|
||||
<p>
|
||||
To submit your own group or channel, join the directory bot and follow the prompts.
|
||||
Listing is free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Speakers' Corner Online — private communities on the <a href="https://simplex.chat" target="_blank">SimpleX Network</a> — <a href="./index.html">← Back to Directory</a></p>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
100
web/index.html
100
web/index.html
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SimpleXXX Directory</title>
|
||||
<meta name="description" content="Find communities on the SimpleXXX network">
|
||||
<title>Speakers' Corner Online Directory</title>
|
||||
<meta name="description" content="Find communities on the Speakers' Corner Online network">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%230053D0'/%3E%3Cg transform='translate(50,50) rotate(45)'%3E%3Crect x='-34' y='-9' width='68' height='18' fill='%2302C0FF'/%3E%3Crect x='-9' y='-34' width='18' height='68' fill='%2302C0FF'/%3E%3Crect x='-20' y='-5' width='40' height='10' fill='%230053D0'/%3E%3Crect x='-5' y='-20' width='10' height='40' fill='%230053D0'/%3E%3C/g%3E%3C/svg%3E">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -52,6 +52,7 @@
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -321,12 +322,72 @@
|
||||
|
||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Hero banner ── */
|
||||
.hero {
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 28px;
|
||||
position: relative;
|
||||
}
|
||||
.hero img {
|
||||
width: 100%;
|
||||
height: 260px;
|
||||
object-fit: cover;
|
||||
object-position: center 40%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dir-summary {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
max-width: 600px;
|
||||
margin: 0 auto 28px;
|
||||
}
|
||||
|
||||
/* ── QR popout ── */
|
||||
.qr-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 16px; border-radius: 20px;
|
||||
border: 1.5px solid var(--accent); background: transparent;
|
||||
color: var(--accent); font-size: 13px; font-weight: 600;
|
||||
font-family: inherit; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.qr-btn:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
|
||||
.qr-popout {
|
||||
display: none;
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
background: rgba(0,0,0,0.55);
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.qr-popout.open { display: flex; }
|
||||
.qr-box {
|
||||
background: var(--card-bg); border-radius: 20px;
|
||||
padding: 28px; max-width: 320px; width: 90%;
|
||||
text-align: center; box-shadow: 0 24px 60px rgba(0,0,0,0.35);
|
||||
position: relative;
|
||||
}
|
||||
.qr-box img { width: 100%; border-radius: 10px; display: block; }
|
||||
.qr-box p { margin-top: 14px; font-size: 13px; color: var(--muted); line-height: 1.5; }
|
||||
.qr-close {
|
||||
position: absolute; top: 12px; right: 16px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
font-size: 20px; color: var(--muted); line-height: 1;
|
||||
}
|
||||
.qr-close:hover { color: var(--text); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#directory .entry { flex-direction: column; }
|
||||
#directory .entry a.img-link { margin-right: 0; }
|
||||
#directory .entry a.img-link img { width: 72px; height: 72px; min-width: 72px; min-height: 72px; border-radius: 16px; }
|
||||
.search-container { flex-direction: column; align-items: stretch; }
|
||||
.sort-tabs { justify-content: center; }
|
||||
.hero img { height: 160px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -334,12 +395,43 @@
|
||||
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<span class="logo-text">SimpleXXX Directory</span>
|
||||
<a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc"
|
||||
target="_blank" style="text-decoration:none;">
|
||||
<span class="logo-text">Speakers' Corner Online Directory</span>
|
||||
</a>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<a href="./about.html" style="color:var(--accent);font-size:13px;font-weight:600;text-decoration:none;">About</a>
|
||||
<button class="qr-btn" onclick="document.getElementById('qr-popout').classList.add('open')">
|
||||
▦ Scan QR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- QR popout overlay -->
|
||||
<div id="qr-popout" class="qr-popout" onclick="if(event.target===this)this.classList.remove('open')">
|
||||
<div class="qr-box">
|
||||
<button class="qr-close" onclick="document.getElementById('qr-popout').classList.remove('open')">×</button>
|
||||
<img src="./SC-QR.png" alt="Speakers' Corner Online Directory QR code">
|
||||
<p>Scan with the SimpleX app to connect to the directory, or <a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc" target="_blank" style="color:var(--accent);">tap here</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h1>SimpleXXX Directory</h1>
|
||||
<h1>
|
||||
<a href="https://smp6.simplex.im/a#Puih5QVZOvfdnMamqJ_KBcj86dwqOJjy3sYZxKMpJnc"
|
||||
target="_blank" style="color:inherit;text-decoration:none;">Speakers' Corner Online Directory</a>
|
||||
</h1>
|
||||
|
||||
<div class="hero">
|
||||
<img src="./thedigitalartist-flag-4628030_1920.jpg" alt="Speakers' Corner Online Directory">
|
||||
</div>
|
||||
|
||||
<p class="dir-summary">
|
||||
A community-run directory of groups and channels on the
|
||||
<a href="https://simplex.chat" target="_blank" style="color:var(--accent);text-decoration:none;">SimpleX Network</a>.
|
||||
Browse by activity, discover new communities, and join with one tap — no phone number or account required.
|
||||
</p>
|
||||
|
||||
<!-- Groups / Channels tabs -->
|
||||
<div class="section-tabs">
|
||||
|
||||
BIN
web/thedigitalartist-flag-4628030_1920.jpg
Normal file
BIN
web/thedigitalartist-flag-4628030_1920.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 994 KiB |
Reference in New Issue
Block a user