637 lines
24 KiB
Python
637 lines
24 KiB
Python
"""Interactive CLI client for encrypted chat (X3DH + Double Ratchet)."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
|
|
from chat_core import ChatClient
|
|
|
|
|
|
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())
|
|
|
|
|
|
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 c["name"]
|
|
others = [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 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("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
|
|
ok, msg = await client.send_message(conv_id, text, members)
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
elif choice == "2":
|
|
conv, _ = await _select_conversation(client)
|
|
if not conv:
|
|
continue
|
|
text = await prompt("Message: ")
|
|
if not text:
|
|
continue
|
|
ok, msg = await client.send_message(conv["conversation_id"], text, conv["members"])
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
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
|
|
ok, msg = await client.send_message(conv["conversation_id"], text, conv["members"], reply_to=reply_to_id)
|
|
print(f"[{'+'if ok else '!'}] {msg}")
|
|
|
|
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
|
|
ok, msg = await client.send_image(conv["conversation_id"], image_path, conv["members"])
|
|
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
|
|
ok, msg = await client.send_file(conv["conversation_id"], file_path, conv["members"])
|
|
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 = r.get("sender", "???")
|
|
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 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: {img.get('filename', '?')} ({_human_size(img.get('size', 0))})]"
|
|
file_info = ""
|
|
if m.get("file"):
|
|
fi = m["file"]
|
|
file_info = f" [File: {fi.get('filename', '?')} ({_human_size(fi.get('size', 0))})]"
|
|
read_info = ""
|
|
if m.get("sender") == client.username:
|
|
read_by = m.get("read_by", [])
|
|
member_map = {}
|
|
for mem in conv.get("members", []):
|
|
uid = mem.get("user_id") or mem.get("id", "")
|
|
if uid:
|
|
member_map[uid] = 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]
|
|
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}]"
|
|
else:
|
|
read_info = " [\u2713 Sent]"
|
|
text = m.get("text", "")
|
|
print(f" #{i+1} {m['sender']}: {text}{image_info}{file_info}{reply_info}{read_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 = 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 = inv.get("conversation_name") or inv.get("conversation_id", "")[:8]
|
|
invited_by = 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: {profile.get('username', '?')}")
|
|
print(f" Email: {profile.get('email', '?')}")
|
|
phone = profile.get("phone")
|
|
if phone:
|
|
print(f" Phone: {phone}")
|
|
location = profile.get("location")
|
|
if location:
|
|
print(f" Location: {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 = d.get("device_name") or "Unnamed"
|
|
did = d.get("device_id", "?")
|
|
last_seen = 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 = data.get("conversation_name", "?")
|
|
invited_by = 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 in ("user_online", "user_offline", "online_users"):
|
|
continue # Silent for CLI
|
|
payload = client.decrypt_notification(data)
|
|
if payload:
|
|
print(f"\n[*] New message from {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 (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 (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 (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 (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 (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.")
|