E2E encrypted chat (X3DH + Double Ratchet, Signal Protocol). Server: asyncio TCP + TLS, MySQL. Clients: PyQt6 GUI + CLI. Secrets (.env, TLS keys, Cloudflare token), runtime data and mobile clients (separate repos) are gitignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
929 lines
37 KiB
Python
929 lines
37 KiB
Python
"""Interactive CLI client for encrypted chat (X3DH + Double Ratchet)."""
|
|
|
|
import asyncio
|
|
import getpass
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
from chat_core import ChatClient, IdentityKeyChanged
|
|
|
|
|
|
def setup_logging():
|
|
level_name = os.getenv("LOG_LEVEL", "WARNING").upper()
|
|
level = getattr(logging, level_name, logging.WARNING)
|
|
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
|
|
|
|
|
|
async def prompt(text: str) -> str:
|
|
"""Non-blocking terminal input."""
|
|
return await asyncio.get_event_loop().run_in_executor(None, lambda: input(text).strip())
|
|
|
|
|
|
async def prompt_password(text: str = "Password: ") -> str:
|
|
"""Non-blocking hidden password input (M3 fix)."""
|
|
return await asyncio.get_event_loop().run_in_executor(None, lambda: getpass.getpass(text))
|
|
|
|
|
|
# M3 fix: strip terminal control/escape sequences from untrusted text
|
|
_CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]")
|
|
|
|
|
|
def _sanitize_text(s) -> str:
|
|
"""Remove control characters and ANSI escape sequences."""
|
|
if not isinstance(s, str):
|
|
s = str(s) if s is not None else ""
|
|
return _CONTROL_RE.sub("", s)
|
|
|
|
|
|
def _safe_filename(name: str) -> str:
|
|
"""Sanitize remote filename: basename only, no path traversal, no NUL."""
|
|
name = os.path.basename(name)
|
|
name = name.replace("\x00", "")
|
|
if not name or name.startswith("."):
|
|
name = "download"
|
|
return name
|
|
|
|
|
|
def _human_size(n: int) -> str:
|
|
if n >= 1024 * 1024:
|
|
return f"{n / (1024*1024):.1f} MB"
|
|
if n >= 1024:
|
|
return f"{n / 1024:.0f} KB"
|
|
return f"{n} B"
|
|
|
|
|
|
async def _select_conversation(client: ChatClient, label: str = "Select conversation") -> tuple[dict | None, list[dict]]:
|
|
"""List conversations and let user pick one. Returns (conv, convs) or (None, [])."""
|
|
convs = await client.list_conversations()
|
|
if not convs:
|
|
print("[*] No conversations.")
|
|
return None, []
|
|
|
|
def conv_label(c):
|
|
if c.get("name"):
|
|
return _sanitize_text(c["name"])
|
|
others = [_sanitize_text(m.get("username") or m.get("email") or "?") for m in c["members"] if m.get("email") != client.email]
|
|
return ", ".join(others) if others else _sanitize_text(client.username)
|
|
|
|
print()
|
|
for i, c in enumerate(convs):
|
|
print(f" {i+1}) {conv_label(c)}")
|
|
choice = await prompt(f"{label}: ")
|
|
try:
|
|
idx = int(choice) - 1
|
|
if not (0 <= idx < len(convs)):
|
|
print("[!] Invalid selection.")
|
|
return None, convs
|
|
except ValueError:
|
|
print("[!] Invalid selection.")
|
|
return None, convs
|
|
return convs[idx], convs
|
|
|
|
|
|
async def interactive_menu(client: ChatClient):
|
|
"""Interactive terminal menu."""
|
|
while True:
|
|
print("\n--- Encrypted Chat ---")
|
|
print("1) Send direct message")
|
|
print("2) Send to conversation")
|
|
print("3) Read messages")
|
|
print("4) Create group conversation")
|
|
print("5) Add member to group")
|
|
print("6) Send image")
|
|
print("7) Send file")
|
|
print("8) Invitations")
|
|
print("9) Leave group")
|
|
print("10) Rename group")
|
|
print("11) Delete conversation")
|
|
print("12) Search messages")
|
|
print("13) My profile")
|
|
print("14) View user profile")
|
|
print("15) Manage devices")
|
|
print("16) React to message")
|
|
print("17) Pin/Unpin message")
|
|
print("18) View pinned messages")
|
|
print("19) Forward message")
|
|
print("20) Verify contact")
|
|
print("21) Show my fingerprint")
|
|
print("22) Change password")
|
|
print("23) Change username")
|
|
print("q) Quit")
|
|
|
|
choice = await prompt("> ")
|
|
|
|
if choice == "1":
|
|
email = await prompt("To (email): ")
|
|
if not email:
|
|
continue
|
|
text = await prompt("Message: ")
|
|
if not text:
|
|
continue
|
|
conv_id, msg = await client.find_or_create_conversation(email)
|
|
if not conv_id:
|
|
print(f"[!] {msg}")
|
|
continue
|
|
convs = await client.list_conversations()
|
|
members = []
|
|
for c in convs:
|
|
if c["conversation_id"] == conv_id:
|
|
members = c["members"]
|
|
break
|
|
try:
|
|
ok, result = await client.send_message(conv_id, text, members)
|
|
except IdentityKeyChanged as ikc:
|
|
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
|
|
continue
|
|
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
|
|
|
|
elif choice == "2":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
text = await prompt("Message: ")
|
|
if not text:
|
|
continue
|
|
try:
|
|
ok, result = await client.send_message(conv["conversation_id"], text, conv["members"])
|
|
except IdentityKeyChanged as ikc:
|
|
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
|
|
continue
|
|
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
|
|
|
|
elif choice == "3":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
messages = await client.get_messages(conv["conversation_id"])
|
|
if not messages:
|
|
print("[*] No messages.")
|
|
continue
|
|
_print_messages(messages, client, conv)
|
|
|
|
action = await prompt("\nAction (r=reply, d=delete, dl=download file, empty=back): ")
|
|
if not action:
|
|
continue
|
|
if action.lower().startswith("dl"):
|
|
await _download_file_action(client, messages)
|
|
continue
|
|
if action.lower().startswith("d"):
|
|
await _delete_message_action(client, messages)
|
|
continue
|
|
if action.lower().startswith("r"):
|
|
reply_choice = await prompt("Reply to message #: ")
|
|
else:
|
|
reply_choice = action
|
|
try:
|
|
reply_idx = int(reply_choice) - 1
|
|
if not (0 <= reply_idx < len(messages)):
|
|
print("[!] Invalid message number.")
|
|
continue
|
|
except ValueError:
|
|
print("[!] Invalid number.")
|
|
continue
|
|
reply_to_id = messages[reply_idx]["message_id"]
|
|
text = await prompt("Message: ")
|
|
if not text:
|
|
continue
|
|
try:
|
|
ok, result = await client.send_message(conv["conversation_id"], text, conv["members"], reply_to=reply_to_id)
|
|
except IdentityKeyChanged as ikc:
|
|
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
|
|
continue
|
|
print(f"[{'+'if ok else '!'}] {'Message sent.' if ok else result}")
|
|
|
|
elif choice == "4":
|
|
name = await prompt("Group name (empty for none): ")
|
|
members_input = await prompt("Member emails (comma-separated): ")
|
|
members = [m.strip() for m in members_input.split(",") if m.strip()]
|
|
if not members:
|
|
continue
|
|
conv_id, msg = await client.create_conversation(members, name=name.strip() or None)
|
|
if conv_id:
|
|
print(f"[+] Group created with: {', '.join(members)}")
|
|
else:
|
|
print(f"[!] {msg}")
|
|
|
|
elif choice == "5":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
email = await prompt("Email to add: ")
|
|
ok, msg = await client.add_member(conv["conversation_id"], email)
|
|
print(f"[{'+'if ok else '!'}] {msg or 'Invitation sent.'}")
|
|
|
|
elif choice == "6":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
image_path = await prompt("Image path: ")
|
|
if not image_path:
|
|
continue
|
|
try:
|
|
ok, msg = await client.send_image(conv["conversation_id"], image_path, conv["members"])
|
|
except IdentityKeyChanged as ikc:
|
|
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
|
|
continue
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
elif choice == "7":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
file_path = await prompt("File path: ")
|
|
if not file_path:
|
|
continue
|
|
if not os.path.isfile(file_path):
|
|
print("[!] File not found.")
|
|
continue
|
|
try:
|
|
ok, msg = await client.send_file(conv["conversation_id"], file_path, conv["members"])
|
|
except IdentityKeyChanged as ikc:
|
|
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
|
|
continue
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
elif choice == "8":
|
|
await _invitations_menu(client)
|
|
|
|
elif choice == "9":
|
|
conv, _ = await _select_conversation(client, "Select group to leave")
|
|
if not conv:
|
|
continue
|
|
confirm = await prompt(f"Leave '{conv.get('name', 'this conversation')}'? (y/n): ")
|
|
if confirm.lower() != "y":
|
|
continue
|
|
ok, msg = await client.leave_group(conv["conversation_id"])
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
elif choice == "10":
|
|
conv, _ = await _select_conversation(client, "Select group to rename")
|
|
if not conv:
|
|
continue
|
|
name = await prompt("New name: ")
|
|
if not name:
|
|
continue
|
|
ok, msg = await client.rename_conversation(conv["conversation_id"], name.strip())
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
elif choice == "11":
|
|
conv, _ = await _select_conversation(client, "Select conversation to delete")
|
|
if not conv:
|
|
continue
|
|
confirm = await prompt("Delete this conversation? This cannot be undone. (y/n): ")
|
|
if confirm.lower() != "y":
|
|
continue
|
|
ok, msg = await client.delete_conversation(conv["conversation_id"])
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
elif choice == "12":
|
|
conv, _ = await _select_conversation(client, "Select conversation to search")
|
|
if not conv:
|
|
continue
|
|
query = await prompt("Search query: ")
|
|
if not query:
|
|
continue
|
|
# First ensure we have messages cached by fetching them
|
|
await client.get_messages(conv["conversation_id"])
|
|
results = client.search_messages(conv["conversation_id"], query)
|
|
if not results:
|
|
print("[*] No matches found.")
|
|
continue
|
|
print(f"\n[*] {len(results)} match(es):")
|
|
for r in results:
|
|
sender = _sanitize_text(r.get("sender", "???"))
|
|
text = _sanitize_text(r.get("text", ""))
|
|
ts = r.get("created_at", "")[:16]
|
|
# Highlight match in text
|
|
idx = text.lower().find(query.lower())
|
|
if idx >= 0:
|
|
text = text[:idx] + "\033[33m" + text[idx:idx+len(query)] + "\033[0m" + text[idx+len(query):]
|
|
print(f" [{ts}] {sender}: {text}")
|
|
|
|
elif choice == "13":
|
|
await _my_profile_menu(client)
|
|
|
|
elif choice == "14":
|
|
email = await prompt("User email: ")
|
|
if not email:
|
|
continue
|
|
# Need to find user_id from email — try via conversation members
|
|
user_id = None
|
|
convs = await client.list_conversations()
|
|
for c in convs:
|
|
for m in c.get("members", []):
|
|
if m.get("email") == email:
|
|
user_id = m.get("user_id") or m.get("id")
|
|
break
|
|
if user_id:
|
|
break
|
|
if not user_id:
|
|
print("[!] User not found in your conversations.")
|
|
continue
|
|
profile = await client.get_profile(user_id)
|
|
if not profile:
|
|
print("[!] Could not load profile.")
|
|
continue
|
|
_print_profile(profile)
|
|
|
|
elif choice == "15":
|
|
await _devices_menu(client)
|
|
|
|
elif choice == "16":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
messages = await client.get_messages(conv["conversation_id"])
|
|
if not messages:
|
|
print("[*] No messages.")
|
|
continue
|
|
_print_messages(messages, client, conv)
|
|
msg_choice = await prompt("React to message #: ")
|
|
try:
|
|
msg_idx = int(msg_choice) - 1
|
|
if not (0 <= msg_idx < len(messages)):
|
|
print("[!] Invalid message number.")
|
|
continue
|
|
except ValueError:
|
|
print("[!] Invalid number.")
|
|
continue
|
|
print("Reactions: thumbsup, heart, laugh, surprised, sad, thumbsdown")
|
|
reaction = await prompt("Reaction: ").strip().lower()
|
|
if reaction not in ("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"):
|
|
print("[!] Invalid reaction.")
|
|
continue
|
|
ok, msg = await client.react_message(messages[msg_idx]["message_id"], reaction, "add")
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
elif choice == "17":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
messages = await client.get_messages(conv["conversation_id"])
|
|
if not messages:
|
|
print("[*] No messages.")
|
|
continue
|
|
_print_messages(messages, client, conv)
|
|
msg_choice = await prompt("Pin/Unpin message #: ")
|
|
try:
|
|
msg_idx = int(msg_choice) - 1
|
|
if not (0 <= msg_idx < len(messages)):
|
|
print("[!] Invalid message number.")
|
|
continue
|
|
except ValueError:
|
|
print("[!] Invalid number.")
|
|
continue
|
|
m = messages[msg_idx]
|
|
action = "unpin" if m.get("pinned_at") else "pin"
|
|
ok, msg = await client.pin_message(m["message_id"], conv["conversation_id"], action)
|
|
print(f"[{'+'if ok else '!'}] {action.capitalize()}: {msg}")
|
|
|
|
elif choice == "18":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
pinned = await client.get_pinned_messages(conv["conversation_id"])
|
|
if not pinned:
|
|
print("[*] No pinned messages.")
|
|
continue
|
|
print(f"\n[*] {len(pinned)} pinned message(s):")
|
|
for p in pinned:
|
|
print(f" {p.get('message_id', '?')[:8]}... pinned at {p.get('pinned_at', '?')}")
|
|
|
|
elif choice == "19":
|
|
conv, _ = await _select_conversation(client, "Select source conversation")
|
|
if not conv:
|
|
continue
|
|
messages = await client.get_messages(conv["conversation_id"])
|
|
if not messages:
|
|
print("[*] No messages.")
|
|
continue
|
|
_print_messages(messages, client, conv)
|
|
msg_choice = await prompt("Forward message #: ")
|
|
try:
|
|
msg_idx = int(msg_choice) - 1
|
|
if not (0 <= msg_idx < len(messages)):
|
|
print("[!] Invalid message number.")
|
|
continue
|
|
except ValueError:
|
|
print("[!] Invalid number.")
|
|
continue
|
|
target_conv, _ = await _select_conversation(client, "Select target conversation")
|
|
if not target_conv:
|
|
continue
|
|
fwd_msg = messages[msg_idx]
|
|
fwd_msg["conversation_id"] = conv["conversation_id"]
|
|
try:
|
|
ok, result = await client.forward_message(
|
|
target_conv["conversation_id"], fwd_msg, target_conv["members"]
|
|
)
|
|
except IdentityKeyChanged as ikc:
|
|
print(f"[!] Identity key changed for {ikc.user_id[:8]}. Accept the new key before sending.")
|
|
continue
|
|
print(f"[{'+'if ok else '!'}] {'Forwarded.' if ok else result}")
|
|
|
|
elif choice == "20":
|
|
# Verify contact — show safety number for a DM conversation
|
|
conv, _ = await _select_conversation(client, "Select DM to verify")
|
|
if not conv:
|
|
continue
|
|
# Find peer user_id
|
|
peer_uid = ""
|
|
peer_name = ""
|
|
for m in conv.get("members", []):
|
|
if m.get("email") != client.email:
|
|
peer_uid = m.get("user_id") or m.get("id") or ""
|
|
peer_name = _sanitize_text(m.get("username") or m.get("email") or "?")
|
|
break
|
|
if not peer_uid:
|
|
print("[!] Could not identify peer user.")
|
|
continue
|
|
# Ensure we have their identity key in cache
|
|
info = await client._get_user_info(user_id=peer_uid)
|
|
if not info or not info.get("identity_key_bytes"):
|
|
print("[!] Could not retrieve identity key for this user.")
|
|
continue
|
|
status = client.get_verification_status(peer_uid)
|
|
print(f"\n--- Verification: {peer_name} ---")
|
|
print(f"Status: {status.upper()}")
|
|
safety = client.get_safety_number(peer_uid)
|
|
if safety:
|
|
print(f"\nSafety Number:\n{safety}")
|
|
fp = client.get_peer_fingerprint(peer_uid)
|
|
if fp:
|
|
print(f"\nTheir Fingerprint:\n{fp}")
|
|
my_fp = client.get_my_fingerprint()
|
|
if my_fp:
|
|
print(f"\nYour Fingerprint:\n{my_fp}")
|
|
if status != "verified":
|
|
action = await prompt("\nMark as verified? (y/n): ")
|
|
if action.lower() == "y":
|
|
client.verify_contact(peer_uid, info["identity_key_bytes"],
|
|
method="safety_number")
|
|
print("[+] Contact marked as verified.")
|
|
else:
|
|
action = await prompt("\nRemove verification? (y/n): ")
|
|
if action.lower() == "y":
|
|
client.unverify_contact(peer_uid)
|
|
print("[+] Verification removed.")
|
|
|
|
elif choice == "21":
|
|
# Show own fingerprint
|
|
fp = client.get_my_fingerprint()
|
|
if fp:
|
|
print(f"\n--- Your Fingerprint ---\n{fp}")
|
|
else:
|
|
print("[!] Not logged in or identity key not available.")
|
|
|
|
elif choice == "22":
|
|
# Change password
|
|
old_pw = getpass.getpass("Current password: ")
|
|
new_pw = getpass.getpass("New password: ")
|
|
confirm_pw = getpass.getpass("Confirm new password: ")
|
|
if new_pw != confirm_pw:
|
|
print("[!] Passwords do not match.")
|
|
elif not new_pw:
|
|
print("[!] Password cannot be empty.")
|
|
else:
|
|
ok, msg = client.change_password(old_pw, new_pw)
|
|
if ok:
|
|
print(f"[+] {msg}")
|
|
else:
|
|
print(f"[!] {msg}")
|
|
|
|
elif choice == "23":
|
|
new_un = await prompt("New username: ")
|
|
new_un = new_un.strip() if new_un else ""
|
|
if not new_un:
|
|
print("[!] Username cannot be empty.")
|
|
else:
|
|
ok, msg = await client.change_username(new_un)
|
|
if ok:
|
|
print(f"[+] {msg}")
|
|
else:
|
|
print(f"[!] {msg}")
|
|
|
|
elif choice in ("q", "Q", "quit", "exit"):
|
|
print("[*] Bye.")
|
|
break
|
|
|
|
|
|
def _print_messages(messages, client, conv):
|
|
"""Print messages to terminal."""
|
|
print()
|
|
for i, m in enumerate(messages):
|
|
if m.get("deleted"):
|
|
print(f" #{i+1} [Message deleted]")
|
|
continue
|
|
reply_info = ""
|
|
if m.get("reply_to"):
|
|
for j, orig in enumerate(messages):
|
|
if orig["message_id"] == m["reply_to"]:
|
|
reply_info = f" (reply to #{j+1})"
|
|
break
|
|
else:
|
|
reply_info = " (reply to older message)"
|
|
image_info = ""
|
|
if m.get("image"):
|
|
img = m["image"]
|
|
image_info = f" [Image: {_sanitize_text(img.get('filename', '?'))} ({_human_size(img.get('size', 0))})]"
|
|
file_info = ""
|
|
if m.get("file"):
|
|
fi = m["file"]
|
|
file_info = f" [File: {_sanitize_text(fi.get('filename', '?'))} ({_human_size(fi.get('size', 0))})]"
|
|
read_info = ""
|
|
if m.get("sender") == client.username:
|
|
read_by = m.get("read_by", [])
|
|
delivered_to = m.get("delivered_to", [])
|
|
member_map = {}
|
|
for mem in conv.get("members", []):
|
|
uid = mem.get("user_id") or mem.get("id", "")
|
|
if uid:
|
|
member_map[uid] = _sanitize_text(mem.get("username") or mem.get("email") or "?")
|
|
my_uid = client.session.get("user_id", "") if client.session else ""
|
|
others_read = [r for r in read_by if r.get("user_id") != my_uid]
|
|
others_delivered = [d for d in delivered_to if d.get("user_id") != my_uid]
|
|
if others_read:
|
|
names = ", ".join(member_map.get(r["user_id"], r["user_id"][:8]) for r in others_read)
|
|
read_info = f" [\u2713\u2713 Read by {names}]"
|
|
elif others_delivered:
|
|
read_info = " [\u2713\u2713 Delivered]"
|
|
else:
|
|
read_info = " [\u2713 Sent]"
|
|
pin_info = ""
|
|
if m.get("pinned_at"):
|
|
pin_info = " \U0001f4cc"
|
|
reaction_info = ""
|
|
reactions = m.get("reactions", [])
|
|
if reactions:
|
|
grouped = {}
|
|
for r in reactions:
|
|
grouped.setdefault(r["reaction"], 0)
|
|
grouped[r["reaction"]] += 1
|
|
_REMOJI = {"thumbsup": "\U0001f44d", "heart": "\u2764\ufe0f", "laugh": "\U0001f602",
|
|
"surprised": "\U0001f62e", "sad": "\U0001f622", "thumbsdown": "\U0001f44e"}
|
|
parts = [f"{_REMOJI.get(k, k)}{v}" for k, v in grouped.items()]
|
|
reaction_info = " [" + " ".join(parts) + "]"
|
|
fwd_info = ""
|
|
if m.get("forwarded_from"):
|
|
fwd_sender = _sanitize_text(m["forwarded_from"].get("sender", "?"))
|
|
fwd_info = f" (fwd from {fwd_sender})"
|
|
text = _sanitize_text(m.get("text", ""))
|
|
sender = _sanitize_text(m.get("sender", "?"))
|
|
print(f" #{i+1} {sender}: {text}{image_info}{file_info}{reply_info}{read_info}{pin_info}{reaction_info}{fwd_info}")
|
|
|
|
|
|
async def _delete_message_action(client, messages):
|
|
del_choice = await prompt("Delete message #: ")
|
|
try:
|
|
del_idx = int(del_choice) - 1
|
|
if not (0 <= del_idx < len(messages)):
|
|
print("[!] Invalid message number.")
|
|
return
|
|
except ValueError:
|
|
print("[!] Invalid number.")
|
|
return
|
|
ok, msg = await client.delete_message(messages[del_idx]["message_id"])
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
|
|
async def _download_file_action(client, messages):
|
|
dl_choice = await prompt("Download from message #: ")
|
|
try:
|
|
dl_idx = int(dl_choice) - 1
|
|
if not (0 <= dl_idx < len(messages)):
|
|
print("[!] Invalid message number.")
|
|
return
|
|
except ValueError:
|
|
print("[!] Invalid number.")
|
|
return
|
|
m = messages[dl_idx]
|
|
file_info = m.get("file") or m.get("image")
|
|
if not file_info:
|
|
print("[!] No file/image in this message.")
|
|
return
|
|
filename = _safe_filename(file_info.get("filename", "download"))
|
|
save_path = await prompt(f"Save as [{filename}]: ")
|
|
if not save_path:
|
|
save_path = filename
|
|
data = await client.download_file(file_info["file_id"], file_info)
|
|
if data:
|
|
with open(save_path, "wb") as f:
|
|
f.write(data)
|
|
print(f"[+] Saved to {save_path} ({_human_size(len(data))})")
|
|
else:
|
|
print("[!] Download failed.")
|
|
|
|
|
|
async def _invitations_menu(client):
|
|
invitations = await client.list_invitations()
|
|
if not invitations:
|
|
print("[*] No pending invitations.")
|
|
return
|
|
print("\nPending invitations:")
|
|
for i, inv in enumerate(invitations):
|
|
inv_name = _sanitize_text(inv.get("conversation_name") or inv.get("conversation_id", "")[:8])
|
|
invited_by = _sanitize_text(inv.get("invited_by_username") or inv.get("invited_by", "")[:8])
|
|
print(f" {i+1}) {inv_name} (invited by {invited_by})")
|
|
choice = await prompt("Select invitation (or empty to go back): ")
|
|
if not choice:
|
|
return
|
|
try:
|
|
idx = int(choice) - 1
|
|
if not (0 <= idx < len(invitations)):
|
|
print("[!] Invalid selection.")
|
|
return
|
|
except ValueError:
|
|
print("[!] Invalid selection.")
|
|
return
|
|
inv = invitations[idx]
|
|
action = await prompt("(a)ccept or (d)ecline? ")
|
|
if action.lower().startswith("a"):
|
|
ok, msg = await client.accept_invitation(inv["conversation_id"])
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
elif action.lower().startswith("d"):
|
|
ok, msg = await client.decline_invitation(inv["conversation_id"])
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
|
|
def _print_profile(profile):
|
|
print(f"\n Username: {_sanitize_text(profile.get('username', '?'))}")
|
|
print(f" Email: {_sanitize_text(profile.get('email', '?'))}")
|
|
phone = profile.get("phone")
|
|
if phone:
|
|
print(f" Phone: {_sanitize_text(phone)}")
|
|
location = profile.get("location")
|
|
if location:
|
|
print(f" Location: {_sanitize_text(location)}")
|
|
has_avatar = profile.get("avatar_file")
|
|
print(f" Avatar: {'Yes' if has_avatar else 'No'}")
|
|
|
|
|
|
async def _my_profile_menu(client):
|
|
profile = await client.get_profile()
|
|
if not profile:
|
|
print("[!] Could not load profile.")
|
|
return
|
|
print("\n--- My Profile ---")
|
|
_print_profile(profile)
|
|
print(f" Phone visible: {profile.get('phone_visible', False)}")
|
|
print(f" Email visible: {profile.get('email_visible', False)}")
|
|
print(f" Location visible: {profile.get('location_visible', False)}")
|
|
|
|
action = await prompt("\n(e)dit, (a)vatar upload, or empty to go back: ")
|
|
if not action:
|
|
return
|
|
if action.lower().startswith("e"):
|
|
print("[*] Leave fields empty to keep current value.")
|
|
phone = await prompt(f"Phone [{profile.get('phone', '')}]: ")
|
|
location = await prompt(f"Location [{profile.get('location', '')}]: ")
|
|
phone_vis = await prompt(f"Phone visible [{profile.get('phone_visible', False)}] (y/n): ")
|
|
email_vis = await prompt(f"Email visible [{profile.get('email_visible', False)}] (y/n): ")
|
|
loc_vis = await prompt(f"Location visible [{profile.get('location_visible', False)}] (y/n): ")
|
|
|
|
fields = {}
|
|
if phone:
|
|
fields["phone"] = phone
|
|
if location:
|
|
fields["location"] = location
|
|
if phone_vis.lower() in ("y", "n"):
|
|
fields["phone_visible"] = phone_vis.lower() == "y"
|
|
if email_vis.lower() in ("y", "n"):
|
|
fields["email_visible"] = email_vis.lower() == "y"
|
|
if loc_vis.lower() in ("y", "n"):
|
|
fields["location_visible"] = loc_vis.lower() == "y"
|
|
if fields:
|
|
ok, msg = await client.update_profile(**fields)
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
else:
|
|
print("[*] No changes.")
|
|
elif action.lower().startswith("a"):
|
|
path = await prompt("Avatar image path: ")
|
|
if not path or not os.path.isfile(path):
|
|
print("[!] File not found.")
|
|
return
|
|
data = open(path, "rb").read()
|
|
ok, msg = await client.update_avatar(data)
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
|
|
async def _devices_menu(client):
|
|
resp = await client.send_and_recv("list_devices")
|
|
if resp.get("status") != "ok":
|
|
print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")
|
|
return
|
|
devices = resp["data"].get("devices", [])
|
|
if not devices:
|
|
print("[*] No devices found.")
|
|
return
|
|
current_device_id = client.device_id
|
|
print("\nYour devices:")
|
|
for i, d in enumerate(devices):
|
|
name = _sanitize_text(d.get("device_name") or "Unnamed")
|
|
did = d.get("device_id", "?")
|
|
last_seen = _sanitize_text(d.get("last_seen_at", "?"))
|
|
current = " (this device)" if did == current_device_id else ""
|
|
print(f" {i+1}) {name} — {did[:8]}... — last seen: {last_seen}{current}")
|
|
action = await prompt("\n(r)emove a device, or empty to go back: ")
|
|
if not action or not action.lower().startswith("r"):
|
|
return
|
|
choice = await prompt("Remove device #: ")
|
|
try:
|
|
idx = int(choice) - 1
|
|
if not (0 <= idx < len(devices)):
|
|
print("[!] Invalid selection.")
|
|
return
|
|
except ValueError:
|
|
print("[!] Invalid selection.")
|
|
return
|
|
d = devices[idx]
|
|
if d.get("device_id") == current_device_id:
|
|
print("[!] Cannot remove current device.")
|
|
return
|
|
resp = await client.send_and_recv("remove_device", device_id=d["device_id"])
|
|
if resp.get("status") == "ok":
|
|
print("[+] Device removed.")
|
|
else:
|
|
print(f"[!] {resp.get('data', {}).get('message', 'Failed')}")
|
|
|
|
|
|
async def notification_printer(client: ChatClient):
|
|
"""Print real-time notifications with sender name."""
|
|
while True:
|
|
notif = await client._notification_queue.get()
|
|
notif_type = notif.get("type", "")
|
|
data = notif.get("data", {})
|
|
if notif_type == "messages_read":
|
|
continue # Silent - read receipts shown when reading messages
|
|
if notif_type == "typing_start":
|
|
who = _sanitize_text(data.get("username") or data.get("user_id", "")[:8] or "Someone")
|
|
print(f"\n[*] {who} is typing...")
|
|
continue
|
|
if notif_type == "typing_stop":
|
|
continue
|
|
if notif_type == "session_reset":
|
|
from_uid = data.get("from_user_id", "")[:8]
|
|
client.handle_session_reset_notification(
|
|
data.get("from_user_id", ""),
|
|
data.get("from_device_id") or None,
|
|
)
|
|
print(f"\n[*] Session with {from_uid}... was reset. New session will be created on next message.")
|
|
continue
|
|
if notif_type == "group_invitation":
|
|
inv_name = _sanitize_text(data.get("conversation_name", "?"))
|
|
invited_by = _sanitize_text(data.get("invited_by_username", "?"))
|
|
print(f"\n[*] New invitation to '{inv_name}' from {invited_by}. Use option 8 to accept/decline.")
|
|
continue
|
|
if notif_type == "device_added":
|
|
device_name = _sanitize_text(data.get("device_name", "Unknown device"))
|
|
device_id = _sanitize_text((data.get("device_id", "") or "")[:8])
|
|
ip = _sanitize_text(data.get("ip", "unknown"))
|
|
print(
|
|
f"\n[!] New device added to your account: {device_name} ({device_id}) from {ip}.\n"
|
|
f" If this was not you, rotate keys immediately."
|
|
)
|
|
continue
|
|
if notif_type in ("conversation_created", "member_removed", "conversation_renamed"):
|
|
print(f"\n[*] Conversation updated ({notif_type}).")
|
|
continue
|
|
if notif_type == "member_added":
|
|
print(f"\n[*] Conversation updated (member_added).")
|
|
conv_id = data.get("conversation_id", "")
|
|
new_user_id = data.get("user_id", "")
|
|
if conv_id and new_user_id:
|
|
asyncio.ensure_future(
|
|
client.redistribute_sender_key_to_member(conv_id, new_user_id)
|
|
)
|
|
continue
|
|
if notif_type == "message_reacted":
|
|
username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
|
|
reaction = _sanitize_text(data.get("reaction", "?"))
|
|
action = data.get("action", "add")
|
|
print(f"\n[*] {username} {'added' if action == 'add' else 'removed'} reaction '{reaction}'")
|
|
continue
|
|
if notif_type in ("message_pinned", "message_unpinned"):
|
|
username = _sanitize_text(data.get("username", data.get("user_id", "?")[:8]))
|
|
act = "pinned" if notif_type == "message_pinned" else "unpinned"
|
|
print(f"\n[*] {username} {act} a message")
|
|
continue
|
|
if notif_type in ("user_online", "user_offline", "online_users"):
|
|
continue # Silent for CLI
|
|
payload = client.decrypt_notification(data)
|
|
if payload:
|
|
print(f"\n[*] New message from {_sanitize_text(payload['sender'])} in conversation {data.get('conversation_id', '?')[:8]}...")
|
|
# None = control message (sender key distribution), skip silently
|
|
|
|
|
|
async def main():
|
|
setup_logging()
|
|
client = ChatClient()
|
|
await client.connect()
|
|
|
|
client._listener_task = asyncio.create_task(client._background_listener())
|
|
notif_task = asyncio.create_task(notification_printer(client))
|
|
|
|
print("=== Encrypted Chat Client ===")
|
|
print("1) Register")
|
|
print("2) Login")
|
|
print("3) Link new device (this device)")
|
|
print("4) Authorize new device (from this device)")
|
|
print("5) Rotate keys (revoke other devices)")
|
|
choice = await prompt("> ")
|
|
|
|
if choice == "1":
|
|
username = await prompt("Username (display): ")
|
|
email = await prompt("Email: ")
|
|
password = await prompt_password("Password (for private key): ")
|
|
if not email or not password:
|
|
print("[!] Email and password required.")
|
|
await client.close()
|
|
return
|
|
ok, code_or_msg = await client.register(username, password, email=email)
|
|
if not ok:
|
|
print(f"[!] {code_or_msg}")
|
|
await client.close()
|
|
return
|
|
print(f"[*] Registration code: {code_or_msg}")
|
|
code = await prompt("Enter code: ")
|
|
ok2, msg2 = await client.confirm_registration(email, username, code)
|
|
print(f"[{'+'if ok2 else '!'}] {msg2}")
|
|
if ok2:
|
|
ok3, msg3 = await client.login(email, password)
|
|
print(f"[{'+'if ok3 else '!'}] {msg3}")
|
|
elif choice == "2":
|
|
email = await prompt("Email: ")
|
|
password = await prompt_password("Password (for private key): ")
|
|
ok, msg = await client.login(email, password)
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
elif choice == "3":
|
|
email = await prompt("Email: ")
|
|
password = await prompt_password("Password (for private key): ")
|
|
if not password:
|
|
print("[!] Password required.")
|
|
await client.close()
|
|
return
|
|
ok, code_or_msg = await client.pairing_start(email)
|
|
if not ok:
|
|
print(f"[!] {code_or_msg}")
|
|
await client.close()
|
|
return
|
|
code = code_or_msg
|
|
fingerprint = client.pairing_fingerprint()
|
|
print(f"[*] Pairing code: {code}")
|
|
print("[*] Pairing fingerprint:")
|
|
print(fingerprint)
|
|
print("[*] Approve this code on an already-logged-in device.")
|
|
print("[!] Never share this pairing code.")
|
|
ok2, msg2 = await client.pairing_wait(code, email, password)
|
|
if not ok2:
|
|
print(f"[!] {msg2}")
|
|
await client.close()
|
|
return
|
|
print(f"[+] {msg2}")
|
|
ok3, msg3 = await client.login(email, password)
|
|
print(f"[{'+'if ok3 else '!'}] {msg3}")
|
|
elif choice == "4":
|
|
email = await prompt("Email: ")
|
|
password = await prompt_password("Password (for private key): ")
|
|
ok, msg = await client.login(email, password)
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
if not ok:
|
|
await client.close()
|
|
return
|
|
code = await prompt("Pairing code: ")
|
|
fingerprint = await prompt("Fingerprint shown on the new device: ")
|
|
ok2, msg2 = await client.authorize_device(code, fingerprint)
|
|
print(f"[{'+'if ok2 else '!'}] {msg2}")
|
|
elif choice == "5":
|
|
email = await prompt("Email: ")
|
|
password = await prompt_password("Password (for private key): ")
|
|
ok, msg = await client.login(email, password)
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
if not ok:
|
|
await client.close()
|
|
return
|
|
confirm = await prompt("This will revoke other devices. Type 'YES' to continue: ")
|
|
if confirm != "YES":
|
|
print("[*] Cancelled.")
|
|
await client.close()
|
|
return
|
|
ok2, msg2 = await client.rotate_keys(client.username, password)
|
|
print(f"[{'+'if ok2 else '!'}] {msg2}")
|
|
else:
|
|
print("[!] Invalid choice.")
|
|
await client.close()
|
|
return
|
|
|
|
if client.session:
|
|
await interactive_menu(client)
|
|
|
|
notif_task.cancel()
|
|
await client.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
print("\n[*] Bye.")
|