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>
This commit is contained in:
@@ -163,6 +163,7 @@ class RunningBot:
|
||||
rss_seen: set = field(default_factory=set) # rss: entry ids already posted
|
||||
poll_next: float = 0.0 # next scheduled poll (epoch seconds)
|
||||
channel_gid: int | None = None # broadcast channel group id
|
||||
file_cache: dict[int, dict] = field(default_factory=dict) # file_id → {name, path, crypto_args}
|
||||
|
||||
|
||||
# profile_id → RunningBot
|
||||
@@ -242,15 +243,41 @@ 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:
|
||||
async def send_to_chat(
|
||||
profile_id: int, chat_type: str, chat_id: int, text: str,
|
||||
reply_to_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Send a message directly to a chat by its (type, id) ref. Raises on failure."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
await b.chat.api_send_text_message({"chatType": chat_type, "chatId": chat_id}, text)
|
||||
await b.chat.api_send_text_message(
|
||||
{"chatType": chat_type, "chatId": chat_id}, text, in_reply_to=reply_to_id
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def send_reaction(
|
||||
profile_id: int, chat_type: str, chat_id: int,
|
||||
item_id: int, emoji: str, add: bool = True,
|
||||
) -> None:
|
||||
"""Add or remove an emoji reaction on a message."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
await b.chat.api_chat_item_reaction(
|
||||
chat_type, chat_id, item_id, add, {"type": "emoji", "emoji": emoji}
|
||||
)
|
||||
|
||||
|
||||
async def accept_file(profile_id: int, file_id: int) -> None:
|
||||
"""Accept an incoming file transfer (moves it from rcvInvitation → downloading)."""
|
||||
b = get_running(profile_id)
|
||||
if not b or not b.chat:
|
||||
raise RuntimeError("Profile is not running")
|
||||
await b.chat.api_receive_file(file_id)
|
||||
|
||||
|
||||
async def refresh_lists(profile_id: int) -> None:
|
||||
"""Re-fetch contacts and groups (with channel classification) for a running profile.
|
||||
|
||||
@@ -275,24 +302,75 @@ async def refresh_lists(profile_id: int) -> None:
|
||||
|
||||
|
||||
def _normalize_item(ci: dict) -> dict:
|
||||
"""Flatten a ChatItem into {id, ts, text, outgoing, sender} for the UI."""
|
||||
"""Flatten a ChatItem into a dict for the UI, including reactions, quote, file."""
|
||||
meta = ci.get("meta", {})
|
||||
chat_dir = ci.get("chatDir", {})
|
||||
dir_type = chat_dir.get("type", "")
|
||||
outgoing = dir_type.endswith("Snd")
|
||||
msg_content = ci.get("content", {}).get("msgContent", {})
|
||||
content_type = msg_content.get("type", "text")
|
||||
# Prefer meta.itemText; fall back to content.msgContent.text
|
||||
text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "")
|
||||
text = meta.get("itemText") or msg_content.get("text", "")
|
||||
# Inline image/video preview (base64 data URI embedded in the message)
|
||||
image_preview = msg_content.get("image", "") if content_type in ("image", "video") else ""
|
||||
# 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", "")
|
||||
|
||||
# Emoji reactions: [{emoji, count, me}]
|
||||
reactions = []
|
||||
for rc in ci.get("reactions", []):
|
||||
r = rc.get("reaction", {})
|
||||
if r.get("type") == "emoji":
|
||||
reactions.append({
|
||||
"emoji": r["emoji"],
|
||||
"count": rc.get("totalReacted", 0),
|
||||
"me": rc.get("userReacted", False),
|
||||
})
|
||||
|
||||
# Quoted/reply item
|
||||
quote = None
|
||||
qi = ci.get("quotedItem")
|
||||
if qi:
|
||||
q_content = qi.get("content", {})
|
||||
q_text = q_content.get("text", "")
|
||||
q_dir = qi.get("chatDir") or {}
|
||||
q_dir_type = q_dir.get("type", "")
|
||||
if q_dir_type in ("directSnd", "groupSnd"):
|
||||
q_sender = "You"
|
||||
elif q_dir_type == "groupRcv":
|
||||
q_sender = q_dir.get("groupMember", {}).get("localDisplayName", "")
|
||||
else:
|
||||
q_sender = ""
|
||||
quote = {"id": qi.get("itemId"), "text": q_text, "sender": q_sender}
|
||||
|
||||
# File attachment
|
||||
file_info = None
|
||||
f = ci.get("file")
|
||||
if f:
|
||||
status = f.get("fileStatus", {}).get("type", "")
|
||||
path = (f.get("fileSource") or {}).get("filePath", "")
|
||||
file_info = {
|
||||
"id": f.get("fileId"),
|
||||
"name": f.get("fileName", ""),
|
||||
"size": f.get("fileSize", 0),
|
||||
"status": status,
|
||||
"path": path,
|
||||
}
|
||||
|
||||
return {
|
||||
"id": meta.get("itemId"),
|
||||
"ts": meta.get("itemTs", ""),
|
||||
"text": text,
|
||||
"content_type": content_type,
|
||||
"image_preview": image_preview,
|
||||
"outgoing": outgoing,
|
||||
"sender": sender,
|
||||
"deleted": "itemDeleted" in meta,
|
||||
"reactions": reactions,
|
||||
"quote": quote,
|
||||
"file": file_info,
|
||||
}
|
||||
|
||||
|
||||
@@ -305,7 +383,37 @@ async def get_chat_history(
|
||||
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]
|
||||
normalized = []
|
||||
for ci in items:
|
||||
norm = _normalize_item(ci)
|
||||
f = ci.get("file")
|
||||
if f and f.get("fileId"):
|
||||
src = f.get("fileSource") or {}
|
||||
b.file_cache[f["fileId"]] = {
|
||||
"name": f.get("fileName", "file"),
|
||||
"path": src.get("filePath", ""),
|
||||
"crypto_args": src.get("cryptoArgs"),
|
||||
}
|
||||
normalized.append(norm)
|
||||
return normalized
|
||||
|
||||
|
||||
async def read_file_bytes(profile_id: int, file_id: int) -> tuple[bytes, str]:
|
||||
"""Return (raw_bytes, filename) for a downloaded file, decrypting if needed."""
|
||||
b = get_running(profile_id)
|
||||
if not b:
|
||||
raise RuntimeError("Profile is not running")
|
||||
entry = b.file_cache.get(file_id)
|
||||
if not entry or not entry.get("path"):
|
||||
raise FileNotFoundError(f"File {file_id} not found in cache — load the chat first")
|
||||
path = entry["path"]
|
||||
crypto_args = entry.get("crypto_args")
|
||||
if crypto_args:
|
||||
from simplex_chat import core as sc_core
|
||||
data = await sc_core.chat_read_file(path, crypto_args)
|
||||
else:
|
||||
data = Path(path).read_bytes()
|
||||
return data, entry["name"]
|
||||
|
||||
|
||||
async def clear_chat(profile_id: int, chat_type: str, chat_id: int) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user