Compare commits

..

33 Commits

Author SHA1 Message Date
Jon
7c712c9ee3 Rich chat messages (reactions, replies, files, images); RSS poll countdown; Speakers' Corner directory page updates
- Chat: extract reactions, quoted replies, file/image data in _normalize_item
- Chat: render emoji reaction pills, reply-quote blocks, inline image previews, file blocks with Accept/Download
- Chat: reply UI (hover → set reply → preview bar above compose → send with quotedItemId)
- Chat: emoji picker strip (6 quick-react emojis) on message hover
- Chat: POST /react and POST /file/{id}/receive and GET /file/{id}/download endpoints
- Chat: file decryption via core.chat_read_file (native libsimplex FFI), served with correct MIME type
- List: RSS bot cards show live next-poll countdown (ticks every second via status API poll_next field)
- Directory: rename SimpleXXX → Speakers' Corner Online Directory throughout
- Directory: add hero banner image, About page link, QR popout, title hyperlink
- Directory: new about.html — Online Safety Act, Digital ID, 65k arrests stat, community rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:23:00 +01:00
Jon
432e4a5e83 Rename RSS category label to 'RSS' (keep '+ New RSS Bot'); move RSS below File Upload
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:51:16 +01:00
Jon
895cc6ddfa Split RSS bots into their own category
New RSS_TYPES category with a /rss-bots page, sidebar entry, homepage tile, and an
explanation that RSS bots post a feed to a channel — share the channel link, not
the user. Remove rss from the Bots page (types table + create dropdown); the RSS
Bots page has a '+ New RSS Bot' button that only creates rss bots, with feed URL +
per hour/day/week fields shown directly (no bot-type select).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:43:45 +01:00
Jon
87029f6d2a RSS bot: poll interval as radio buttons (per hour/day/week)
Replace the seconds number input with Per hour / Per day / Per week radios
(3600/86400/604800s), default per hour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 23:19:27 +01:00
Jon
28a4c22ef3 RSS bot: raise initial channel fill cap from 5 to 20
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:56:29 +01:00
Jon
332b4a1801 Add show/hide Link + QR toggles for every SimpleX link (default hidden)
Reusable link box (Jinja macro in _macros.html + shared JS/CSS/QR lib in
base.html): a 'Link' button toggles the URL (with copy) and a 'QR' button toggles
a lazily-rendered QR of the same link — both hidden by default. Applied to the
profile address, profile groups & channels, and the profile cards on the list
pages. Centralize robustCopy in base.html; drop the per-page duplicates and the
old async group-link fetch (groups now use their known link).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:46:26 +01:00
Jon
3456ed9411 Add 'crypto' bot: streams CoinGecko prices to a channel
New crypto bot type: creates a broadcast channel and posts a price snapshot of the
selected coins/currencies (CoinGecko simple/price JSON) every interval — same
channel-streaming model as RSS. Create form has checkbox grids for popular coins
and currencies plus a poll interval. Generalize the channel helper and feed-poll
state (channel_gid/poll_next) shared by rss + crypto. Adds crypto_test.py (mock
CoinGecko) — passes; rss_test updated for the renamed field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:31:44 +01:00
Jon
7cda767408 RSS bot: populate channel on first run so joiners see content
Previously the bot seeded all existing feed items on startup WITHOUT posting, so a
freshly-created channel stayed empty and new subscribers saw nothing (only items
appearing after start were posted). Now on first run it posts the latest items
(max 5) to fill the channel — recent history then shows them to joiners — and sets
an rss_populated flag so restarts don't replay. Existing (empty) bots get filled
once on next start. Update rss_test.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:02:15 +01:00
Jon
a43061e096 Fix link/address copy over plain-HTTP LAN; show group link URL inline
navigator.clipboard only works in a secure context, so copy silently failed when
served over a LAN IP on http. Add a robustCopy() with a textarea+execCommand
fallback (used by group-link, address and channel-link copy). The group/channel
'Link' button now toggles a visible, selectable URL row beneath the group with a
working copy button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:48:09 +01:00
Jon
1bd0bd9c7b RSS bot: drop /new command — purely publishing
Remove the /new command, the on-connect latest-items send, the _rss_send_latest
helper and the now-unused rss_items cache. The bot only watches the feed and
broadcasts new posts to its channel. Update welcome + UI copy to drop /new.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:33:58 +01:00
Jon
908d16bfc3 Add 'rss' bot: broadcasts an RSS/Atom feed to a channel
New rss bot type: on start it creates a broadcast channel (observer group with
recent history on) and polls a configured feed URL; new posts are broadcast to
the channel. Subscribers join the channel link (seen on the bot's profile);
direct contacts get a welcome + the latest items and can send /new for the
latest. Stdlib-only feed parsing (urllib + ElementTree), seeds existing items on
startup so it doesn't replay the whole feed. Config: feed_url, poll_seconds.
Adds rss_test.py (mock feed) — passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:08:05 +01:00
Jon
12d21e6de5 Swap Bots/Business Groups order (tiles + sidebar); move clock below network info
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:49:51 +01:00
Jon
9c083dc6d9 Add sidebar clock (24h time + day-of-week date)
Small clock in the sidebar footer: HH:MM (24h) with weekday+date below; collapsed
sidebar shows just the time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:44:55 +01:00
Jon
7f12820eb3 Collapsed sidebar shows only the status dot; rename Businesses → Business Groups
Collapsed sidebar now hides the network text (running/servers/operators) and
keeps just the centered status dot. Rename the user-facing 'Businesses' label to
'Business Groups' (sidebar, homepage tile, list title/heading/empty state, create
button, profile back-link); route/tab id stay 'businesses'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:22:41 +01:00
Jon
62489b84b7 Remove sidebar brand diamond; color homepage title with sidebar accent
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:14:56 +01:00
Jon
4ed2f9ba14 UI: Font Awesome icons, Material touches, 3-line collapse button
Swap all emoji icons for Font Awesome (sidebar, homepage tiles, profile/chat/
list/settings/relay actions); add Roboto font + card elevation hover for a
Material feel. Replace the bottom 'Collapse' pill with a 3-line (fa-bars) toggle
in the sidebar header; remove the old collapse pill CSS/JS. Copy buttons toggle
FA check/copy via innerHTML. Plain text status (✓/✗) and back arrows kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:06:59 +01:00
Jon
1881b74d92 Notifications card: show unread count bubble via the shared poll
pollNotifications now updates every .notif-badge (sidebar nav + homepage card)
from the same /api/notifications source, so the card bubble shows the unread
count and clears in lockstep with the sidebar. Add a badge to the card; scope
the collapsed-only badge positioning to the sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:40:21 +01:00
Jon
ea5efb06d8 Align sidebar + homepage groups; flatten Relays (drop dropdown); move Notifications to system group
Both the sidebar and the homepage now share the same logical groups:
accounts (Users/Businesses/Bots/File Upload), relays (Chat/File/Message Relay),
system (Network/Notifications/Settings), external (Get App). Relays is now its
own flat divider-separated section in the sidebar instead of a collapsible
dropdown; remove the dropdown CSS/JS. Move Notifications into the system group
on the homepage cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:15:46 +01:00
Jon
d3a5cb18e4 Sidebar: add Network menu item; drop redundant 'Network' label from status block
Add a Network entry to the sidebar nav (grouped with Notifications/Settings under
one divider). Remove the 'Network ›' title above the bottom status rows — the
running/servers/operators info is self-explanatory; drop its now-unused CSS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:07:48 +01:00
Jon
aaf3c23a18 Add 'llm' bot: OpenAI-compatible chat (Ollama-ready)
New 'llm' bot type that takes a startup context (system prompt) and replies to
each message via an OpenAI-compatible endpoint — works with a local Ollama
(ollama serve, http://localhost:11434/v1), OpenAI, Grok, etc. Generalize the
support LLM handler into _handle_llm_message (shared by support + llm) with a
per-bot default prompt. Create form reuses the LLM fields (URL/key/model/context)
for both support and llm. Adds llm_test.py (mock OpenAI backend) — passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:58:54 +01:00
Jon
3f0338041c Home: compact row tiles, centered title, closing bottom bar; File Relay out-arrow icon
Tiles now lay icon + title on one row (more compact); the SimpleX Manager title
is centered over the cards; add a closing faded bar at the bottom. File Relay
icon changed to an out-arrow tray (closest emoji to a folder-with-arrow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:48:36 +01:00
Jon
37925edcdf Home cards: drop descriptions; give File Relay a distinct icon
Remove the t-desc lines from the homepage tiles (icon + title only). Change File
Relay icon from the folder (same as File Upload) to a card-dividers icon, in both
the homepage card and the sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:44:53 +01:00
Jon
22b5ee7203 Title-case titles: Chat/File/Message Relay and File Upload
Capitalize Relay and Upload in the relay titles and the File Upload entry
(sidebar, homepage cards, relay page title via RELAY_KINDS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:42:03 +01:00
Jon
5a5134f9b2 Add Relays group (chat/file/message) + faded homepage dividers
New collapsible 'Relays' group in the sidebar (chat/file/message relay) that
expands to show its items and persists open state. Homepage gets a matching
Relays card group, and areas are now split by faded separator bars like the
sidebar. Relay pages are placeholder 'coming soon' stubs (/relays/{kind}).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:38:27 +01:00
Jon
3f9683e07f Homepage: move File upload + Notifications to top area; sidebar: add Get App below Settings
Top area now holds Users/Businesses/Bots/Notifications/File upload; manage area is
Network/Settings; external area is Get SimpleX App. Add a padded Get App link
(nav-sep) below Settings in the sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:30:29 +01:00
Jon
34469455a4 Restore Get SimpleX App link in the footer
It still appears as a homepage card; keep the footer link too (removing it
earlier was not requested).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:25:58 +01:00
Jon
2e298e1438 Group homepage tiles into vertically-buffered areas; add Get SimpleX App card
Split the home tiles into three areas with vertical spacing: accounts
(Users/Businesses/Bots), manage (Network/Notifications/Settings), and external
SimpleX (File upload + a new Get SimpleX App card). Move the Get SimpleX App link
out of the global footer into its own homepage card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:16:39 +01:00
Jon
964d5e1efa Tidy homepage subtitle out; restyle sidebar collapse button
Remove the homepage tagline. Make the collapse control a proper inset pill with
a 'Collapse' label + chevron (label left, chevron right) instead of a bare
full-width row with a lone arrow; centers to just the icon when collapsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:11:31 +01:00
Jon
270766b99b Add tiled homepage; rename Business→Businesses; link footer copyright
- Home page (home.html) at / shows the sidebar sections as tiles; reachable by
  clicking the 'SimpleX Manager' brand in the sidebar (was redirecting to /users).
- Rename the category to 'Businesses' (route /businesses, tab/nav/_category),
  keeping the per-account bot_type 'business'. Fix profile back-link label.
- Footer: link 'Bournemouth Technology Ltd' -> bournemouthtechnology.co.uk and
  'SimpleX Network' -> simplex.chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:45:28 +01:00
Jon
2194aa0f82 Fix sidebar clipping on mobile (Logout cut off below the fold)
The sidebar was height:100vh with no overflow, so on mobile the bottom-pinned
footer (network status + Logout) fell below the visible area (browser toolbar)
with no way to reach it. Add overflow-y:auto and use 100dvh on mobile so the
sidebar matches the visible viewport; 100vh kept as fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:27:53 +01:00
Jon
609e91c6de Add 'business' profile type and category
Business accounts are cli profiles with businessAddress=True, so each connecting
customer gets their own group chat (handled via the existing chat UI) with an
optional welcome auto-reply. New BUSINESS_TYPES, a /business page + sidebar entry,
and a business variant of the create form. profile/chat pages route via a
_category helper. Adds business_test.py (customer connects -> lands in a business
group, not a direct contact) — passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 17:09:00 +01:00
Jon
6232c1589d Broadcast bot: parity with official simplex-broadcast-bot
Relay publishers' text/links to all contacts via the native /feed command
(reports 'Forwarded to N contact(s), M errors'); reply to non-publishers with
the prohibited message and internally delete their message (CIDMInternal, as
upstream does). Filter content to text/links. Publishers accept 'Name' or
'ID:Name'; welcome/prohibited defaults list the publishers. Add publishers +
prohibited-reply fields to the create form. Adds broadcast_test.py (3 in-process
controllers: bot + publisher + subscriber) — passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:27:56 +01:00
Jon
c1bb9cb955 Add chat actions, channels/groups mgmt, directory + deadmans bots, network status
Profiles/bots (profiles.py, main.py):
- Surface real send errors; fix group send and member-count staleness (refresh on view)
- Group/channel actions: join, leave, owner delete; consistent member counts
- Contacts: always-visible Chat, plus Clear chat and Delete contact
- Support bot: OpenAI-compatible LLM backend (Grok/Ollama/OpenAI) per-bot config
- Deadmans bot: check-in window, trigger message, recipients, owner
- Directory bot: add-to-group registration, super-user /approve /reject /list, search,
  publishes listing.json in the website schema (directory.py registry module)
- Profile edit (name/bio/avatar) + avatars on list pages
- Global status + /network page (operators, SMP/XFTP servers) and Settings network info

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

Ignore runtime bot state and generated directory sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:26:16 +01:00
25 changed files with 3826 additions and 183 deletions

5
.gitignore vendored
View File

@@ -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
View 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
View 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
View 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()))

View File

@@ -61,6 +61,13 @@ def update_address(profile_id: int, address: str) -> None:
conn.execute("UPDATE profiles SET address=? WHERE id=?", (address, profile_id))
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
View File

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

150
manager/llm_test.py Normal file
View 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()))

View File

@@ -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)

File diff suppressed because it is too large Load Diff

134
manager/rss_test.py Normal file
View 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()))

View File

@@ -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

View 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 %}

View File

@@ -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 &amp; 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>&nbsp;running</span></div>
<div class="ss-row ss-text"><i class="fa-solid fa-server" style="width:14px;text-align:center;"></i>&nbsp;<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>

View File

@@ -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()">&#x2715;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 %}

View 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 %}

View File

@@ -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 &amp; 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',

View File

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

View File

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

View File

@@ -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';
if (data.ok) { location.reload(); }
else { btn.textContent = 'Join'; btn.disabled = false; alert('Failed to join: ' + (data.detail || 'unknown')); }
}
setTimeout(() => btn.textContent = orig, 2000);
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) {

View 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 isnt implemented yet.</p>
</div>
{% endblock %}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

348
web/about.html Normal file
View 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">&#128274;</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">&#127820;</span>
<h2>Digital Identity &amp; 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">&#128640;</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">&#128220;</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">&#128226;</span>
<h2>Reporting &amp; 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 &mdash; private communities on the <a href="https://simplex.chat" target="_blank">SimpleX Network</a> &mdash; <a href="./index.html">← Back to Directory</a></p>
</footer>
</body>
</html>

View File

@@ -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')">
&#9638; 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')">&times;</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">

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB