diff --git a/.gitignore b/.gitignore index dec65ff..c32c59d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,10 @@ __pycache__/ *.pyc .venv/ *.egg-info/ + +# Downloaded libsimplex artifacts (regenerate via manager/download_sdk.sh) +manager/libs/ +# Throwaway exploration databases +manager/data/explore/ +# Local Claude session resume helper (machine-specific) +manager/ai.sh diff --git a/manager/download_sdk.sh b/manager/download_sdk.sh new file mode 100755 index 0000000..05b71f9 --- /dev/null +++ b/manager/download_sdk.sh @@ -0,0 +1,7 @@ +curl -L --progress-bar \ + "https://github.com/simplex-chat/simplex-chat-libs/releases/download/v6.5.2/simplex-chat-libs-linux-x86_64.zip" \ + -o /tmp/simplex-libs.zip && \ +unzip /tmp/simplex-libs.zip -d /tmp/simplex-libs-extracted && \ +mkdir -p ~/.cache/simplex-chat/v6.5.2/sqlite && \ +mv /tmp/simplex-libs-extracted/libs/* ~/.cache/simplex-chat/v6.5.2/sqlite/ && \ +echo "Done: $(ls ~/.cache/simplex-chat/v6.5.2/sqlite/)" diff --git a/manager/main.py b/manager/main.py index 3b4fd98..125c578 100644 --- a/manager/main.py +++ b/manager/main.py @@ -27,12 +27,10 @@ AUTH_TOKEN = os.environ.get("MANAGER_TOKEN", "changeme") @asynccontextmanager async def lifespan(app: FastAPI): db.init_db() - # Auto-restart any previously running bots on startup for profile in db.list_profiles(): - if profile.get("address"): # had an address = was running before + if profile.get("address"): await pm.start_bot(profile, _save_address) yield - # Graceful shutdown for pid in list(pm._running): await pm.stop_bot(pid) @@ -52,7 +50,6 @@ def _check_auth(request: Request) -> bool: def _require_auth(request: Request) -> None: if not _check_auth(request): - from fastapi.responses import RedirectResponse raise HTTPException(status_code=401, detail="Unauthorized") @@ -62,6 +59,13 @@ def _redirect_if_unauth(request: Request): return None +def _enrich(profiles: list[dict]) -> list[dict]: + for p in profiles: + p["running"] = pm.is_running(p["id"]) + p["config"] = json.loads(p.get("config") or "{}") + return profiles + + # ── Auth ────────────────────────────────────────────────────────────────────── @app.get("/login", response_class=HTMLResponse) @@ -89,18 +93,38 @@ async def logout(): @app.get("/", response_class=HTMLResponse) async def index(request: Request): + return RedirectResponse("/users", status_code=302) + + +@app.get("/users", response_class=HTMLResponse) +async def users_page(request: Request): if redir := _redirect_if_unauth(request): return redir - all_profiles = db.list_profiles() - for p in all_profiles: - p["running"] = pm.is_running(p["id"]) - p["config"] = json.loads(p.get("config") or "{}") - return TEMPLATES.TemplateResponse(request, "index.html", { - "profiles": all_profiles, - "bot_types": pm.BOT_TYPES, + items = _enrich([p for p in db.list_profiles() if p["bot_type"] in pm.USER_TYPES]) + return TEMPLATES.TemplateResponse(request, "list.html", { + "tab": "users", "items": items, "create_types": pm.USER_TYPES, + "nav_active": "users", }) +@app.get("/bots", response_class=HTMLResponse) +async def 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.BOT_TYPES]) + return TEMPLATES.TemplateResponse(request, "list.html", { + "tab": "bots", "items": items, "create_types": pm.BOT_TYPES, + "nav_active": "bots", + }) + + +@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"}) + + @app.get("/profile/{profile_id}", response_class=HTMLResponse) async def profile_page(request: Request, profile_id: int): if redir := _redirect_if_unauth(request): @@ -114,16 +138,90 @@ async def profile_page(request: Request, profile_id: int): 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 + # 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")] + plain_groups = [g for g in groups if not g.get("is_channel")] return TEMPLATES.TemplateResponse(request, "profile.html", { "profile": profile, "contacts": contacts, - "groups": groups, + "groups": plain_groups, + "channels": channels, "log_lines": log_lines, - "bot_types": pm.BOT_TYPES, + "back": "/users" if is_user else "/bots", + "nav_active": "users" if is_user else "bots", }) -# ── API ─────────────────────────────────────────────────────────────────────── +def _find_chat_name(bot, chat_type: str, chat_id: int) -> str: + """Resolve a chat's display name from the running bot's cached lists.""" + if not bot: + return "" + if chat_type == "direct": + for c in bot.contacts: + if c["contactId"] == chat_id: + return c["localDisplayName"] + elif chat_type == "group": + for g in bot.groups: + if pm.group_id(g) == chat_id: + return pm.group_name(g) + return "" + + +@app.get("/profile/{profile_id}/chat/{chat_type}/{chat_id}", response_class=HTMLResponse) +async def chat_room(request: Request, profile_id: int, chat_type: str, chat_id: int): + if redir := _redirect_if_unauth(request): + return redir + profile = db.get_profile(profile_id) + if not profile: + raise HTTPException(404, "Profile not found") + if chat_type not in ("direct", "group"): + raise HTTPException(400, "chat_type must be 'direct' or 'group'") + bot = pm.get_running(profile_id) + name = _find_chat_name(bot, chat_type, chat_id) or f"#{chat_id}" + # Is this group a channel? (affects send affordance: broadcast vs reply) + is_channel = False + if chat_type == "group" and bot: + for g in bot.groups: + 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), + "chat_type": chat_type, + "chat_id": chat_id, + "chat_name": name, + "is_channel": is_channel, + "nav_active": "users" if is_user else "bots", + }) + + +@app.get("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/messages") +async def chat_messages(request: Request, profile_id: int, chat_type: str, chat_id: int): + _require_auth(request) + count = int(request.query_params.get("count", 50)) + try: + messages = await pm.get_chat_history(profile_id, chat_type, chat_id, count) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"messages": messages}) + + +@app.post("/api/profiles/{profile_id}/chat/{chat_type}/{chat_id}/send") +async def chat_send(request: Request, profile_id: int, chat_type: str, chat_id: int): + _require_auth(request) + data = await request.json() + 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) + return JSONResponse({"ok": ok}) + + +# ── Profile API ─────────────────────────────────────────────────────────────── @app.post("/api/profiles") async def create_profile(request: Request): @@ -134,8 +232,8 @@ async def create_profile(request: Request): config = data.get("config", {}) if not name: raise HTTPException(400, "name required") - if bot_type not in pm.BOT_TYPES: - raise HTTPException(400, f"bot_type must be one of {pm.BOT_TYPES}") + if bot_type not in pm.ALL_TYPES: + raise HTTPException(400, f"bot_type must be one of {pm.ALL_TYPES}") try: profile = db.create_profile(name, bot_type, config) except Exception as e: @@ -196,6 +294,50 @@ async def send_message(request: Request, profile_id: int): return JSONResponse({"ok": ok}) +# ── Group / Channel API ─────────────────────────────────────────────────────── + +@app.post("/api/profiles/{profile_id}/groups") +async def create_group(request: Request, profile_id: int): + """Create a group (kind='group', 2-way) or channel (kind='channel', broadcast).""" + _require_auth(request) + profile = db.get_profile(profile_id) + if not profile: + raise HTTPException(404, "Profile not found") + data = await request.json() + name = data.get("name", "").strip() + kind = data.get("kind", "group") + if not name: + raise HTTPException(400, "name required") + try: + if kind == "channel": + link = await pm.create_channel(profile_id, name) + else: + link = await pm.create_group(profile_id, name) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"ok": True, "link": link}) + + +@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) + try: + members = await pm.get_group_members(profile_id, group_id) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"members": members}) + + +@app.get("/api/profiles/{profile_id}/groups/{group_id}/link") +async def group_link(request: Request, profile_id: int, group_id: int): + _require_auth(request) + try: + link = await pm.get_group_link(profile_id, group_id) + except Exception as e: + raise HTTPException(400, str(e)) + return JSONResponse({"link": link}) + + if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/manager/profiles.py b/manager/profiles.py index 8d74462..a1cd981 100644 --- a/manager/profiles.py +++ b/manager/profiles.py @@ -8,7 +8,32 @@ from typing import Any log = logging.getLogger(__name__) -BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"] + +# api_list_groups returns BARE GroupInfo dicts (verified against the live API): +# g["groupId"], g["groupProfile"]["displayName"], +# g["groupSummary"]["currentMembers"], g["membership"]["memberRole"] +# There is no "groupInfo" wrapper and no "members" list in this response. +# +# A "channel" is a group whose join link has acceptMemberRole == "observer" +# (joiners are read-only; only the owner broadcasts). A regular group's link +# has role "member" (2-way). This is the only thing that distinguishes them. + + +def group_name(g: dict) -> str: + return g["groupProfile"]["displayName"] + + +def group_id(g: dict) -> int: + return g["groupId"] + + +def group_member_count(g: dict) -> int: + return g.get("groupSummary", {}).get("currentMembers", 0) + + +BOT_TYPES = ["echo", "broadcast", "support", "directory", "deadmans"] +USER_TYPES = ["user"] +ALL_TYPES = BOT_TYPES + USER_TYPES @dataclass @@ -84,18 +109,16 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo if not b or not b.chat: return False try: - contacts = await b.chat.api_list_contacts(1) - for c in contacts: + for c in b.contacts: if c["localDisplayName"] == contact_or_group: await b.chat.api_send_text_message( {"chatType": "direct", "chatId": c["contactId"]}, text ) return True - groups = await b.chat.api_list_groups(1) - for g in groups: - if g["groupInfo"]["groupProfile"]["displayName"] == contact_or_group: + for g in b.groups: + if group_name(g) == contact_or_group: await b.chat.api_send_text_message( - {"chatType": "group", "chatId": g["groupInfo"]["groupId"]}, text + {"chatType": "group", "chatId": group_id(g)}, text ) return True except Exception as e: @@ -103,6 +126,53 @@ async def send_message(profile_id: int, contact_or_group: str, text: str) -> boo return False +async def send_to_chat(profile_id: int, chat_type: str, chat_id: int, text: str) -> bool: + """Send a message directly to a chat by its (type, id) ref. Returns True on success.""" + b = get_running(profile_id) + if not b or not b.chat: + return False + try: + await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text) + return True + except Exception as e: + log.error("send_to_chat error: %s", e) + return False + + +def _normalize_item(ci: dict) -> dict: + """Flatten a ChatItem into {id, ts, text, outgoing, sender} for the UI.""" + meta = ci.get("meta", {}) + chat_dir = ci.get("chatDir", {}) + dir_type = chat_dir.get("type", "") + outgoing = dir_type.endswith("Snd") + # Prefer meta.itemText; fall back to content.msgContent.text + text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "") + # Sender name: group messages carry the member; direct/own use a generic label + sender = "" + if dir_type == "groupRcv": + sender = chat_dir.get("groupMember", {}).get("localDisplayName", "") + return { + "id": meta.get("itemId"), + "ts": meta.get("itemTs", ""), + "text": text, + "outgoing": outgoing, + "sender": sender, + "deleted": "itemDeleted" in meta, + } + + +async def get_chat_history( + profile_id: int, chat_type: str, chat_id: int, count: int = 50 +) -> list[dict]: + """Return the last `count` messages of a chat, oldest-first, normalized for the UI.""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + chat = await b.chat.api_get_chat(chat_type, chat_id, count) + items = chat.get("chatItems", []) + return [_normalize_item(ci) for ci in items] + + async def _run_bot( profile_id: int, name: str, @@ -123,27 +193,34 @@ async def _run_bot( try: chat = await ChatApi.init(SqliteDb(file_prefix=db_prefix)) b.chat = chat - await chat.start_chat() - # Create or fetch address + # libsimplex /_start requires an active user to exist first user = await chat.api_get_active_user() if not user: user = await chat.api_create_active_user( {"displayName": name, "fullName": ""} ) - user_id = user["userId"] - addr = await chat.api_get_user_address(user_id) - if not addr: - addr = await chat.api_create_user_address(user_id) + await chat.start_chat() - address = addr.get("connShortLink") or addr.get("connFullLink", "") + user_id = user["userId"] + existing = await chat.api_get_user_address(user_id) + if existing: + # api_get_user_address returns UserContactLink; link is nested under connLinkContact + link = existing["connLinkContact"] + else: + # api_create_user_address returns CreatedConnLink directly + link = await chat.api_create_user_address(user_id) + + address = link.get("connShortLink") or link.get("connFullLink", "") b.address = address await on_address(profile_id, address) - # Configure address settings based on bot type + # Configure address settings based on profile type settings: dict = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}} - if bot_type == "support": + if bot_type == "user": + pass # plain user: auto-accept on, no auto-reply + elif bot_type == "support": settings["businessAddress"] = True welcome = config.get("welcome_message", f"Welcome to {name} support.") settings["autoReply"] = {"type": "text", "text": welcome} @@ -157,9 +234,13 @@ async def _run_bot( async def refresh() -> None: try: b.contacts = await chat.api_list_contacts(user_id) - b.groups = await chat.api_list_groups(user_id) + groups = await chat.api_list_groups(user_id) + # Classify each group as channel (observer link) vs group (member link) + for g in groups: + await _classify_group(chat, g) + b.groups = groups except Exception: - pass + log.exception("refresh failed for bot %d", profile_id) await refresh() @@ -241,3 +322,84 @@ def _append_log(b: RunningBot, line: str) -> None: b.log_lines.append(line) if len(b.log_lines) > 200: b.log_lines = b.log_lines[-200:] + + +async def _classify_group(chat: Any, g: dict) -> None: + """Annotate a GroupInfo in place with link info: is_channel, link. + + A channel is a group whose join-link role is "observer". Groups with a + "member"+ link (or no link at all) are regular 2-way groups. + """ + g["is_channel"] = False + g["link"] = "" + try: + link_obj = await chat.api_get_group_link(g["groupId"]) + except Exception: + return # no link → plain private group + g["is_channel"] = link_obj.get("acceptMemberRole") == "observer" + conn = link_obj.get("connLinkContact", {}) + g["link"] = conn.get("connShortLink") or conn.get("connFullLink", "") + + +async def create_channel(profile_id: int, name: str) -> str: + """Create a group with an observer join link (a SimpleX channel). Returns the link.""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + user = await b.chat.api_get_active_user() + if not user: + raise RuntimeError("No active user for this profile") + info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""}) + link = await b.chat.api_create_group_link(info["groupId"], "observer") + # Refresh cached group list (re-classifies all groups including the new channel) + try: + groups = await b.chat.api_list_groups(user["userId"]) + for g in groups: + await _classify_group(b.chat, g) + b.groups = groups + except Exception: + log.exception("group refresh after create_channel failed") + return link + + +async def create_group(profile_id: int, name: str) -> str: + """Create a regular 2-way group with a member join link. Returns the link.""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + user = await b.chat.api_get_active_user() + if not user: + raise RuntimeError("No active user for this profile") + info = await b.chat.api_new_group(user["userId"], {"displayName": name, "fullName": ""}) + link = await b.chat.api_create_group_link(info["groupId"], "member") + try: + groups = await b.chat.api_list_groups(user["userId"]) + for g in groups: + await _classify_group(b.chat, g) + b.groups = groups + except Exception: + log.exception("group refresh after create_group failed") + return link + + +async def get_group_members(profile_id: int, gid: int) -> list[dict]: + """Return the member list for a group/channel (excludes the owner themselves).""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + members = await b.chat.api_list_members(gid) + return [ + {"name": m["localDisplayName"], "role": m["memberRole"], "status": m["memberStatus"]} + for m in members + ] + + +async def get_group_link(profile_id: int, gid: int) -> str: + """Return the existing join link for a group/channel (empty if none).""" + b = get_running(profile_id) + if not b or not b.chat: + raise RuntimeError("Profile is not running") + try: + return await b.chat.api_get_group_link_str(gid) + except Exception: + return "" diff --git a/manager/start.sh b/manager/start.sh index e6b69d1..13fd740 100755 --- a/manager/start.sh +++ b/manager/start.sh @@ -12,6 +12,7 @@ if [ ! -d ".venv" ]; then fi mkdir -p data/bots +mkdir -p static # Set token — override via: MANAGER_TOKEN=mysecret ./start.sh export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}" diff --git a/manager/templates/base.html b/manager/templates/base.html index 951ba63..ed658da 100644 --- a/manager/templates/base.html +++ b/manager/templates/base.html @@ -1,12 +1,19 @@
+| echo | Repeats every message back to the sender — handy for testing a connection end to end. |
| broadcast | Relays messages from authorized publishers out to all of the bot's contacts. |
| support | Business inbox — auto-replies with a welcome message and collects incoming inquiries. |
| directory | Directory service for discovering and listing groups or contacts. |
| deadmans | Dead man's switch — triggers an action if expected check-ins stop arriving. |
Create a SimpleX user account to manage contacts and channels.
+ {% else %} + No bots yet +Bots can echo messages, broadcast to subscribers, or run automated tasks.
+ {% endif %} +Scan QR code from mobile app to start a chat
Start the bot to generate an address.
+Start the profile to generate an address.
{% endif %}| Name | ID |
|---|---|
| Name | |
| {{ c.localDisplayName }} | -{{ c.contactId }} | +
| {{ c.localDisplayName }} | ++ 💬 Chat + |
| Name | Members | |
|---|---|---|
| {{ g.groupInfo.groupProfile.displayName }} | -{{ g.members | length }} | -|
| Name | Members |
No groups yet.
+No groups yet.{% if not profile.running %} Start the profile first.{% endif %}
+ {% endif %} +| Name | Subscribers |
|---|
No channels yet.{% if not profile.running %} Start the profile first.{% endif %}
{% endif %}