initial commit
This commit is contained in:
899
client.py
Normal file
899
client.py
Normal file
@@ -0,0 +1,899 @@
|
||||
"""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 == "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 in ("conversation_created", "member_added", "member_removed", "conversation_renamed"):
|
||||
print(f"\n[*] Conversation updated ({notif_type}).")
|
||||
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
|
||||
print(f"[*] Pairing code: {code}")
|
||||
print("[*] Approve this code on an already-logged-in device.")
|
||||
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: ")
|
||||
ok2, msg2 = await client.authorize_device(code)
|
||||
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.")
|
||||
Reference in New Issue
Block a user