"""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.")