initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

899
client.py Normal file
View 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.")