Add chat rooms, channels, sidebar nav, themes, and UI polish
Backend (profiles.py / main.py): - Fix bot startup crash: create active user before start_chat - Fix address-clobbering bug on restart (UserContactLink vs CreatedConnLink) - Add user profile type alongside bots - Channels: create groups with observer links, classify via acceptMemberRole - Chat rooms: get_chat_history + send_to_chat, history/messages/send routes UI: - Chat room view with message bubbles and live polling - Convert top nav to collapsible left sidebar (mobile-friendly off-canvas) - Three-way Users/Bots split; clickable cards; copy-address buttons + links - Add Matrix theme alongside Original Light/Dark - File upload sidebar link; site footer on all pages incl. login - Bot-type descriptions on the Bots page; QR caption on profiles Remove orphaned index.html (superseded by list.html). Ignore downloaded libs/, exploration DBs, and local ai.sh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user