From e597f803f1d0b7d83cc35483119a81b7808c683e Mon Sep 17 00:00:00 2001 From: o-murphy Date: Fri, 25 Jul 2025 18:41:50 +0300 Subject: [PATCH 1/3] code formatted with ruff --- bitchat.py | 2210 +++++++++++++++++++++------------ compression.py | 7 +- encryption.py | 551 ++++---- fragmentation.py | 37 +- persistence.py | 117 +- terminal_ux.py | 126 +- test_identity_announcement.py | 186 +-- 7 files changed, 2009 insertions(+), 1225 deletions(-) diff --git a/bitchat.py b/bitchat.py index a2bbf7c..2a24537 100644 --- a/bitchat.py +++ b/bitchat.py @@ -24,8 +24,23 @@ from encryption import EncryptionService, NoiseError from compression import compress_if_beneficial, decompress from fragmentation import Fragment, FragmentType, fragment_payload -from terminal_ux import ChatContext, ChatMode, Public, Channel, PrivateDM, format_message_display, print_help, clear_screen -from persistence import AppState, load_state, save_state, encrypt_password, decrypt_password +from terminal_ux import ( + ChatContext, + ChatMode, + Public, + Channel, + PrivateDM, + format_message_display, + print_help, + clear_screen, +) +from persistence import ( + AppState, + load_state, + save_state, + encrypt_password, + decrypt_password, +) # Version VERSION = "v1.1.0" @@ -53,7 +68,8 @@ MSG_FLAG_IS_ENCRYPTED = 0x80 SIGNATURE_SIZE = 64 -BROADCAST_RECIPIENT = b'\xFF' * 8 +BROADCAST_RECIPIENT = b"\xff" * 8 + # Debug levels class DebugLevel(IntEnum): @@ -61,8 +77,10 @@ class DebugLevel(IntEnum): BASIC = 1 FULL = 2 + DEBUG_LEVEL = DebugLevel.CLEAN + def debug_println(*args, **kwargs): if DEBUG_LEVEL >= DebugLevel.BASIC: try: @@ -71,6 +89,7 @@ def debug_println(*args, **kwargs): # Silently ignore blocking errors in debug output pass + def debug_full_println(*args, **kwargs): if DEBUG_LEVEL >= DebugLevel.FULL: try: @@ -79,6 +98,7 @@ def debug_full_println(*args, **kwargs): # Silently ignore blocking errors in debug output pass + # Message types class MessageType(IntEnum): ANNOUNCE = 0x01 @@ -104,10 +124,12 @@ class MessageType(IntEnum): VERSION_HELLO = 0x20 VERSION_ACK = 0x21 + @dataclass class Peer: nickname: Optional[str] = None + @dataclass class BitchatPacket: msg_type: MessageType @@ -118,6 +140,7 @@ class BitchatPacket: payload: bytes ttl: int + @dataclass class BitchatMessage: id: str @@ -126,6 +149,7 @@ class BitchatMessage: is_encrypted: bool encrypted_content: Optional[bytes] + @dataclass class DeliveryAck: original_message_id: str @@ -135,66 +159,86 @@ class DeliveryAck: timestamp: int hop_count: int + class DeliveryTracker: def __init__(self): self.pending_messages: Dict[str, Tuple[str, float, bool]] = {} self.sent_acks: Set[str] = set() - + def track_message(self, message_id: str, content: str, is_private: bool): self.pending_messages[message_id] = (content, time.time(), is_private) - + def mark_delivered(self, message_id: str) -> bool: return self.pending_messages.pop(message_id, None) is not None - + def should_send_ack(self, ack_id: str) -> bool: if ack_id in self.sent_acks: return False self.sent_acks.add(ack_id) return True + class FragmentCollector: def __init__(self): self.fragments: Dict[str, Dict[int, bytes]] = {} self.metadata: Dict[str, Tuple[int, int, str]] = {} - - def add_fragment(self, fragment_id: bytes, index: int, total: int, - original_type: int, data: bytes, sender_id: str) -> Optional[Tuple[bytes, str]]: + + def add_fragment( + self, + fragment_id: bytes, + index: int, + total: int, + original_type: int, + data: bytes, + sender_id: str, + ) -> Optional[Tuple[bytes, str]]: fragment_id_hex = fragment_id.hex() - - debug_full_println(f"[COLLECTOR] Adding fragment {index + 1}/{total} for ID {fragment_id_hex[:8]}") - + + debug_full_println( + f"[COLLECTOR] Adding fragment {index + 1}/{total} for ID {fragment_id_hex[:8]}" + ) + if fragment_id_hex not in self.fragments: - debug_full_println(f"[COLLECTOR] Creating new fragment collection for ID {fragment_id_hex[:8]}") + debug_full_println( + f"[COLLECTOR] Creating new fragment collection for ID {fragment_id_hex[:8]}" + ) self.fragments[fragment_id_hex] = {} self.metadata[fragment_id_hex] = (total, original_type, sender_id) - + fragment_map = self.fragments[fragment_id_hex] fragment_map[index] = data - debug_full_println(f"[COLLECTOR] Fragment {index + 1} stored. Have {len(fragment_map)}/{total} fragments") - + debug_full_println( + f"[COLLECTOR] Fragment {index + 1} stored. Have {len(fragment_map)}/{total} fragments" + ) + if len(fragment_map) == total: debug_full_println("[COLLECTOR] ✓ All fragments received! Reassembling...") - + complete_data = bytearray() for i in range(total): if i in fragment_map: - debug_full_println(f"[COLLECTOR] Appending fragment {i + 1} ({len(fragment_map[i])} bytes)") + debug_full_println( + f"[COLLECTOR] Appending fragment {i + 1} ({len(fragment_map[i])} bytes)" + ) complete_data.extend(fragment_map[i]) else: debug_full_println(f"[COLLECTOR] ✗ Missing fragment {i + 1}") return None - - debug_full_println(f"[COLLECTOR] ✓ Reassembly complete: {len(complete_data)} bytes total") - + + debug_full_println( + f"[COLLECTOR] ✓ Reassembly complete: {len(complete_data)} bytes total" + ) + sender = self.metadata.get(fragment_id_hex, (0, 0, "Unknown"))[2] - + del self.fragments[fragment_id_hex] del self.metadata[fragment_id_hex] - + return (bytes(complete_data), sender) - + return None + class BitchatClient: def __init__(self): self.my_peer_id = os.urandom(8).hex() @@ -218,41 +262,47 @@ def __init__(self): self.running = True self.background_scanner_task = None # Track background scanner task self.disconnection_callback_registered = False - + # Handshake timing tracking (like Swift implementation) self.handshake_attempt_times: Dict[str, float] = {} self.handshake_timeout = 5.0 # 5 seconds before retrying, matching Swift - + # Pending private messages waiting for handshake completion - self.pending_private_messages: Dict[str, List[Tuple[str, str, str]]] = {} # peer_id -> [(content, nickname, message_id)] - + self.pending_private_messages: Dict[ + str, List[Tuple[str, str, str]] + ] = {} # peer_id -> [(content, nickname, message_id)] + # Setup encryption service callbacks for better handshake handling self.encryption_service.on_peer_authenticated = self._on_peer_authenticated self.encryption_service.on_handshake_required = self._on_handshake_required - + def _on_peer_authenticated(self, peer_id: str, fingerprint: str): """Callback when a peer is authenticated via Noise protocol""" - debug_println(f"[NOISE] Peer {peer_id} authenticated with fingerprint: {fingerprint[:16]}...") - + debug_println( + f"[NOISE] Peer {peer_id} authenticated with fingerprint: {fingerprint[:16]}..." + ) + # Send any pending private messages for this peer asyncio.create_task(self.send_pending_private_messages(peer_id)) - + def _on_handshake_required(self, peer_id: str): """Callback when handshake is required for a peer""" debug_println(f"[NOISE] Handshake required for peer {peer_id}") # The handshake will be initiated when trying to send private messages - + async def send_pending_private_messages(self, peer_id: str): """Send all pending private messages for a peer after handshake completes""" if peer_id not in self.pending_private_messages: return - + pending_messages = self.pending_private_messages.pop(peer_id, []) if not pending_messages: return - - debug_println(f"[NOISE] Sending {len(pending_messages)} pending messages to {peer_id}") - + + debug_println( + f"[NOISE] Sending {len(pending_messages)} pending messages to {peer_id}" + ) + for content, nickname, message_id in pending_messages: try: # Add longer delay before sending to allow BLE queue to clear @@ -262,65 +312,70 @@ async def send_pending_private_messages(self, peer_id: str): # Small delay between messages await asyncio.sleep(0.2) except Exception as e: - debug_println(f"[NOISE] Failed to send pending message to {peer_id}: {e}") + debug_println( + f"[NOISE] Failed to send pending message to {peer_id}: {e}" + ) # Re-queue the message if it's a temporary error if "blocking" in str(e).lower(): debug_println(f"[NOISE] Re-queuing message due to BLE congestion") if peer_id not in self.pending_private_messages: self.pending_private_messages[peer_id] = [] - self.pending_private_messages[peer_id].append((content, nickname, message_id)) + self.pending_private_messages[peer_id].append( + (content, nickname, message_id) + ) # Don't retry immediately, let it retry later break - + async def find_device(self) -> Optional[BLEDevice]: """Scan for BitChat service""" debug_println("[1] Scanning for bitchat service...") - + devices = await BleakScanner.discover( - timeout=5.0, - service_uuids=[BITCHAT_SERVICE_UUID] + timeout=5.0, service_uuids=[BITCHAT_SERVICE_UUID] ) - + for device in devices: debug_full_println(f"Found device: {device.name} - {device.address}") return device - + return None - + def handle_disconnect(self, client: BleakClient): """Handle disconnection from peer""" print(f"\r\033[K\033[91m✗ Disconnected from BitChat network\033[0m") print("\033[90m» Scanning for other devices...\033[0m") - print("> ", end='', flush=True) - + print("> ", end="", flush=True) + # Clear connection state self.client = None self.characteristic = None self.peers.clear() # Clear peer list since we're disconnected self.chat_context.active_dms.clear() # Clear DM list - + # Clear encryption sessions (but keep our own identity) self.encryption_service.sessions.clear() self.encryption_service.handshake_states.clear() - + # Clear pending private messages self.pending_private_messages.clear() - + # If in a DM, switch to public if isinstance(self.chat_context.current_mode, PrivateDM): self.chat_context.switch_to_public() - + # Restart background scanner if not already running if not self.background_scanner_task or self.background_scanner_task.done(): - self.background_scanner_task = asyncio.create_task(self.background_scanner()) - + self.background_scanner_task = asyncio.create_task( + self.background_scanner() + ) + async def connect(self): """Connect to BitChat service""" print("\033[90m» Scanning for bitchat service...\033[0m") - + scan_attempts = 0 max_initial_attempts = 10 # Try for ~10 seconds initially - + device = None while not device and self.running: device = await self.find_device() @@ -333,26 +388,30 @@ async def connect(self): print("\033[90m • Other devices are out of Bluetooth range\033[0m") print("\033[90m • The iOS/Android app needs to be open\033[0m") print("\033[90m» Continuing to scan in the background...\033[0m") - print("\033[90m» You can start using commands while waiting.\033[0m") + print( + "\033[90m» You can start using commands while waiting.\033[0m" + ) # Return True to continue without connection return True await asyncio.sleep(1) - + if not self.running: return False - + print("\033[90m» Found bitchat service! Connecting...\033[0m") debug_println("[1] Match Found! Connecting...") - + try: - self.client = BleakClient(device.address, disconnected_callback=self.handle_disconnect) + self.client = BleakClient( + device.address, disconnected_callback=self.handle_disconnect + ) await self.client.connect() - + # Find characteristic services = self.client.services if not services: raise Exception("No services found on device") - + for service in services: for char in service.characteristics: if char.uuid.lower() == BITCHAT_CHARACTERISTIC_UUID.lower(): @@ -361,16 +420,18 @@ async def connect(self): break if self.characteristic: break - + if not self.characteristic: raise Exception("Characteristic not found") - + # Subscribe to notifications - await self.client.start_notify(self.characteristic, self.notification_handler) - + await self.client.start_notify( + self.characteristic, self.notification_handler + ) + debug_println("[2] Connection established.") return True - + except Exception as e: print(f"\n\033[91m❌ Connection failed\033[0m") print(f"\033[90mReason: {e}\033[0m") @@ -380,16 +441,16 @@ async def connect(self): print("\033[90m • You're within range\033[0m") print("\n\033[90mTry running the command again.\033[0m") return False - + async def handshake(self): """Perform initial handshake""" debug_println("[3] Performing handshake...") - + # Load persisted state self.app_state = load_state() if self.app_state.nickname: self.nickname = self.app_state.nickname - + # If we have a connection, send Noise identity announce and regular announce if self.client and self.characteristic: # Send Noise identity announcement first @@ -397,71 +458,92 @@ async def handshake(self): # Create a proper timestamp that matches iOS (milliseconds since epoch) timestamp_ms = int(time.time() * 1000) public_key_bytes = self.encryption_service.get_public_key() - signing_public_key_bytes = self.encryption_service.get_signing_public_key_bytes() - + signing_public_key_bytes = ( + self.encryption_service.get_signing_public_key_bytes() + ) + # Create binding data for signature (matching iOS) # iOS uses: peerID + publicKey + timestamp (as string) - timestamp_data = str(timestamp_ms).encode('utf-8') - binding_data = self.my_peer_id.encode('utf-8') + public_key_bytes + timestamp_data + timestamp_data = str(timestamp_ms).encode("utf-8") + binding_data = ( + self.my_peer_id.encode("utf-8") + public_key_bytes + timestamp_data + ) signature = self.encryption_service.sign_data(binding_data) - + # Encode to binary format identity_payload = self.encode_noise_identity_announcement_binary( - self.my_peer_id, public_key_bytes, signing_public_key_bytes, - self.nickname, timestamp_ms, signature + self.my_peer_id, + public_key_bytes, + signing_public_key_bytes, + self.nickname, + timestamp_ms, + signature, ) - + identity_packet = create_bitchat_packet_with_signature( - self.my_peer_id, MessageType.NOISE_IDENTITY_ANNOUNCE, identity_payload, signature + self.my_peer_id, + MessageType.NOISE_IDENTITY_ANNOUNCE, + identity_payload, + signature, ) await self.send_packet(identity_packet) debug_println("[3] Sent Noise identity announcement (binary format)") except Exception as e: debug_println(f"[3] Failed to send identity announcement: {e}") import traceback + debug_println(f"[3] Traceback: {traceback.format_exc()}") # Fallback to old key exchange - handshake_message = self.encryption_service.initiate_handshake(self.my_peer_id) + handshake_message = self.encryption_service.initiate_handshake( + self.my_peer_id + ) handshake_packet = create_bitchat_packet( self.my_peer_id, MessageType.KEY_EXCHANGE, handshake_message ) await self.send_packet(handshake_packet) - + # Wait a bit between packets await asyncio.sleep(0.5) - + # Send announce announce_packet = create_bitchat_packet( self.my_peer_id, MessageType.ANNOUNCE, self.nickname.encode() ) await self.send_packet(announce_packet) - + debug_println("[3] Handshake sent. You can now chat.") else: debug_println("[3] No connection yet. Skipping handshake.") print("\033[90m» Running in offline mode. Waiting for peers...\033[0m") - + if self.app_state.nickname: print(f"\033[90m» Using saved nickname: {self.nickname}\033[0m") print("\033[90m» Type /status to see connection info\033[0m") - + # Restore state self.blocked_peers = self.app_state.blocked_peers self.channel_creators = self.app_state.channel_creators self.password_protected_channels = self.app_state.password_protected_channels self.channel_key_commitments = self.app_state.channel_key_commitments - + # Restore channel keys from saved passwords if self.app_state.identity_key: - for channel, encrypted_password in self.app_state.encrypted_channel_passwords.items(): + for ( + channel, + encrypted_password, + ) in self.app_state.encrypted_channel_passwords.items(): try: - password = decrypt_password(encrypted_password, self.app_state.identity_key) + password = decrypt_password( + encrypted_password, self.app_state.identity_key + ) key = EncryptionService.derive_channel_key(password, channel) self.channel_keys[channel] = key - debug_println(f"[CHANNEL] Restored key for password-protected channel: {channel}") + debug_println( + f"[CHANNEL] Restored key for password-protected channel: {channel}" + ) except Exception as e: debug_println(f"[CHANNEL] Failed to restore key for {channel}: {e}") - + async def send_packet(self, packet: bytes): """Send packet, with fragmentation if needed""" debug_full_println(f"[RAW SEND] {packet.hex()}") @@ -469,7 +551,7 @@ async def send_packet(self, packet: bytes): debug_println("[!] No connection available. Message queued.") # In a real implementation, we might queue messages here return - + # Check if still connected before sending if not self.client.is_connected: debug_println("[!] Connection lost. Cannot send packet.") @@ -477,7 +559,7 @@ async def send_packet(self, packet: bytes): if self.client: self.handle_disconnect(self.client) return - + if should_fragment(packet): await self.send_packet_with_fragmentation(packet) else: @@ -486,9 +568,7 @@ async def send_packet(self, packet: bytes): # Add small delay to prevent blocking errors await asyncio.sleep(0.01) await self.client.write_gatt_char( - self.characteristic, - packet, - response=write_with_response + self.characteristic, packet, response=write_with_response ) except Exception as e: # Check if this is a connection error @@ -497,16 +577,19 @@ async def send_packet(self, packet: bytes): if self.client: self.handle_disconnect(self.client) return - + # Handle blocking errors by retrying without response - if "could not complete without blocking" in str(e) or write_with_response: + if ( + "could not complete without blocking" in str(e) + or write_with_response + ): try: - debug_println(f"[!] Write blocked, retrying without response after delay") + debug_println( + f"[!] Write blocked, retrying without response after delay" + ) await asyncio.sleep(0.1) # Longer delay for retry await self.client.write_gatt_char( - self.characteristic, - packet, - response=False + self.characteristic, packet, response=False ) debug_println(f"[!] Retry successful") except Exception as e2: @@ -515,36 +598,42 @@ async def send_packet(self, packet: bytes): if self.client: self.handle_disconnect(self.client) elif "could not complete without blocking" in str(e2): - debug_println(f"[!] Write still blocked after retry, dropping packet") + debug_println( + f"[!] Write still blocked after retry, dropping packet" + ) # Don't raise, just log and continue else: raise e2 else: raise e - + async def send_packet_with_fragmentation(self, packet: bytes): """Fragment and send large packets""" if not self.client or not self.characteristic: - debug_println("[!] No connection available. Cannot send fragmented message.") + debug_println( + "[!] No connection available. Cannot send fragmented message." + ) return - + # Check if still connected if not self.client.is_connected: debug_println("[!] Connection lost. Cannot send fragmented packet.") if self.client: self.handle_disconnect(self.client) return - + debug_println(f"[FRAG] Original packet size: {len(packet)} bytes") - + fragment_size = 150 # Conservative size for iOS BLE - chunks = [packet[i:i+fragment_size] for i in range(0, len(packet), fragment_size)] + chunks = [ + packet[i : i + fragment_size] for i in range(0, len(packet), fragment_size) + ] total_fragments = len(chunks) - + fragment_id = os.urandom(8) debug_println(f"[FRAG] Fragment ID: {fragment_id.hex()}") debug_println(f"[FRAG] Total fragments: {total_fragments}") - + for index, chunk in enumerate(chunks): if index == 0: fragment_type = MessageType.FRAGMENT_START @@ -552,75 +641,77 @@ async def send_packet_with_fragmentation(self, packet: bytes): fragment_type = MessageType.FRAGMENT_END else: fragment_type = MessageType.FRAGMENT_CONTINUE - + # Create fragment payload fragment_payload = bytearray() fragment_payload.extend(fragment_id) - fragment_payload.extend(struct.pack('>H', index)) - fragment_payload.extend(struct.pack('>H', total_fragments)) + fragment_payload.extend(struct.pack(">H", index)) + fragment_payload.extend(struct.pack(">H", total_fragments)) fragment_payload.append(MessageType.MESSAGE.value) fragment_payload.extend(chunk) - + fragment_packet = create_bitchat_packet( - self.my_peer_id, - fragment_type, - bytes(fragment_payload) + self.my_peer_id, fragment_type, bytes(fragment_payload) ) - + try: await self.client.write_gatt_char( - self.characteristic, - fragment_packet, - response=False + self.characteristic, fragment_packet, response=False ) - + debug_println(f"[FRAG] ✓ Fragment {index + 1}/{total_fragments} sent") - + if index < len(chunks) - 1: await asyncio.sleep(0.02) # 20ms delay except Exception as e: if "not connected" in str(e).lower(): - debug_println(f"[FRAG] Connection lost while sending fragment {index + 1}") + debug_println( + f"[FRAG] Connection lost while sending fragment {index + 1}" + ) if self.client: self.handle_disconnect(self.client) return else: raise e - + async def notification_handler(self, sender: BleakGATTCharacteristic, data: bytes): """Handle incoming BLE notifications""" try: # Enhanced hex logging to match iOS format - hex_string = ' '.join(f'{b:02X}' for b in data) + hex_string = " ".join(f"{b:02X}" for b in data) debug_full_println(f"[RAW RECV] Received {len(data)} bytes") debug_full_println(f"[RAW RECV] {hex_string}") except BlockingIOError: # If even debug printing fails due to blocking, just silently continue pass - + try: packet = parse_bitchat_packet(data) - + # Ignore our own messages (they are already displayed when sent) if packet.sender_id_str == self.my_peer_id: return - + await self.handle_packet(packet, data) - + except Exception as e: try: debug_full_println(f"[ERROR] Failed to parse packet: {e}") except BlockingIOError: # Silently ignore blocking errors pass - + async def handle_packet(self, packet: BitchatPacket, raw_data: bytes): """Handle incoming packet""" if packet.msg_type == MessageType.ANNOUNCE: await self.handle_announce(packet) elif packet.msg_type == MessageType.MESSAGE: await self.handle_message(packet, raw_data) - elif packet.msg_type in [MessageType.FRAGMENT_START, MessageType.FRAGMENT_CONTINUE, MessageType.FRAGMENT_END]: + elif packet.msg_type in [ + MessageType.FRAGMENT_START, + MessageType.FRAGMENT_CONTINUE, + MessageType.FRAGMENT_END, + ]: await self.handle_fragment(packet, raw_data) elif packet.msg_type == MessageType.KEY_EXCHANGE: await self.handle_key_exchange(packet) @@ -636,83 +727,122 @@ async def handle_packet(self, packet: BitchatPacket, raw_data: bytes): await self.handle_channel_announce(packet) elif packet.msg_type == MessageType.NOISE_IDENTITY_ANNOUNCE: await self.handle_noise_identity_announce(packet) - + async def handle_announce(self, packet: BitchatPacket): """Handle peer announcement""" - peer_nickname = packet.payload.decode('utf-8', errors='ignore').strip() + peer_nickname = packet.payload.decode("utf-8", errors="ignore").strip() is_new_peer = packet.sender_id_str not in self.peers - + if packet.sender_id_str not in self.peers: self.peers[packet.sender_id_str] = Peer() - + self.peers[packet.sender_id_str].nickname = peer_nickname - + if is_new_peer: - print(f"\r\033[K\033[33m{peer_nickname} connected\033[0m\n> ", end='', flush=True) - debug_println(f"[<-- RECV] Announce: Peer {packet.sender_id_str} is now known as '{peer_nickname}'") - + print( + f"\r\033[K\033[33m{peer_nickname} connected\033[0m\n> ", + end="", + flush=True, + ) + debug_println( + f"[<-- RECV] Announce: Peer {packet.sender_id_str} is now known as '{peer_nickname}'" + ) + # Apply tie-breaker logic like iOS client if self.my_peer_id < packet.sender_id_str: # We have lower ID, initiate handshake - debug_println(f"[CRYPTO] Initiating Noise handshake with new peer {packet.sender_id_str} (tie-breaker: we have lower ID)") + debug_println( + f"[CRYPTO] Initiating Noise handshake with new peer {packet.sender_id_str} (tie-breaker: we have lower ID)" + ) try: - handshake_message = self.encryption_service.initiate_handshake(packet.sender_id_str) + handshake_message = self.encryption_service.initiate_handshake( + packet.sender_id_str + ) handshake_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, packet.sender_id_str, MessageType.NOISE_HANDSHAKE_INIT, handshake_message, None + self.my_peer_id, + packet.sender_id_str, + MessageType.NOISE_HANDSHAKE_INIT, + handshake_message, + None, ) # Set TTL to 3 like iOS handshake_data = bytearray(handshake_packet) handshake_data[2] = 3 handshake_packet = bytes(handshake_data) await self.send_packet(handshake_packet) - debug_println(f"[NOISE] Sent handshake init to {packet.sender_id_str}, payload size: {len(handshake_message)}") + debug_println( + f"[NOISE] Sent handshake init to {packet.sender_id_str}, payload size: {len(handshake_message)}" + ) except Exception as e: debug_println(f"[CRYPTO] Failed to initiate handshake: {e}") # Fallback to old key exchange - key_exchange_payload = self.encryption_service.get_combined_public_key_data() + key_exchange_payload = ( + self.encryption_service.get_combined_public_key_data() + ) key_exchange_packet = create_bitchat_packet( self.my_peer_id, MessageType.KEY_EXCHANGE, key_exchange_payload ) await self.send_packet(key_exchange_packet) else: # We have higher ID, send targeted identity announce to prompt them to initiate - debug_println(f"[CRYPTO] Sending targeted identity announce to {packet.sender_id_str} (tie-breaker: they have lower ID)") + debug_println( + f"[CRYPTO] Sending targeted identity announce to {packet.sender_id_str} (tie-breaker: they have lower ID)" + ) try: timestamp_ms = int(time.time() * 1000) public_key_bytes = self.encryption_service.get_public_key() - signing_public_key_bytes = self.encryption_service.get_signing_public_key_bytes() - + signing_public_key_bytes = ( + self.encryption_service.get_signing_public_key_bytes() + ) + # Create binding data for signature - timestamp_data = str(timestamp_ms).encode('utf-8') - binding_data = self.my_peer_id.encode('utf-8') + public_key_bytes + timestamp_data + timestamp_data = str(timestamp_ms).encode("utf-8") + binding_data = ( + self.my_peer_id.encode("utf-8") + + public_key_bytes + + timestamp_data + ) signature = self.encryption_service.sign_data(binding_data) - + # Encode to binary format identity_payload = self.encode_noise_identity_announcement_binary( - self.my_peer_id, public_key_bytes, signing_public_key_bytes, - self.nickname, timestamp_ms, signature + self.my_peer_id, + public_key_bytes, + signing_public_key_bytes, + self.nickname, + timestamp_ms, + signature, ) - + identity_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, packet.sender_id_str, MessageType.NOISE_IDENTITY_ANNOUNCE, - identity_payload, signature + self.my_peer_id, + packet.sender_id_str, + MessageType.NOISE_IDENTITY_ANNOUNCE, + identity_payload, + signature, ) await self.send_packet(identity_packet) except Exception as e: - debug_println(f"[CRYPTO] Failed to send targeted identity announce: {e}") - + debug_println( + f"[CRYPTO] Failed to send targeted identity announce: {e}" + ) + async def handle_message(self, packet: BitchatPacket, raw_data: bytes): """Handle chat message""" # Check if sender is blocked fingerprint = self.encryption_service.get_peer_fingerprint(packet.sender_id_str) if fingerprint and fingerprint in self.blocked_peers: - debug_println(f"[BLOCKED] Ignoring message from blocked peer: {packet.sender_id_str}") + debug_println( + f"[BLOCKED] Ignoring message from blocked peer: {packet.sender_id_str}" + ) return - + # Check if message is for us - is_broadcast = packet.recipient_id == BROADCAST_RECIPIENT if packet.recipient_id else True + is_broadcast = ( + packet.recipient_id == BROADCAST_RECIPIENT if packet.recipient_id else True + ) is_for_us = is_broadcast or (packet.recipient_id_str == self.my_peer_id) - + if not is_for_us: # Relay if TTL > 1 if packet.ttl > 1: @@ -725,7 +855,9 @@ async def handle_message(self, packet: BitchatPacket, raw_data: bytes): decrypted_payload = None if is_private_message: try: - decrypted_payload = self.encryption_service.decrypt_from_peer(packet.sender_id_str, packet.payload) + decrypted_payload = self.encryption_service.decrypt_from_peer( + packet.sender_id_str, packet.payload + ) debug_println("[PRIVATE] Successfully decrypted private message!") except NoiseError: debug_println("[PRIVATE] Failed to decrypt private message") @@ -742,14 +874,22 @@ async def handle_message(self, packet: BitchatPacket, raw_data: bytes): # Add to bloom filter and set self.bloom.add(message.id) self.processed_messages.add(message.id) - + # Display the message await self.display_message(message, packet, is_private_message) - + # Send ACK if needed - if should_send_ack(is_private_message, message.channel, None, self.nickname, len(self.peers)): - await self.send_delivery_ack(message.id, packet.sender_id_str, is_private_message) - + if should_send_ack( + is_private_message, + message.channel, + None, + self.nickname, + len(self.peers), + ): + await self.send_delivery_ack( + message.id, packet.sender_id_str, is_private_message + ) + # Relay if TTL > 1 if packet.ttl > 1: await asyncio.sleep(random.uniform(0.01, 0.05)) @@ -758,47 +898,56 @@ async def handle_message(self, packet: BitchatPacket, raw_data: bytes): await self.send_packet(bytes(relay_data)) else: debug_println(f"[DUPLICATE] Ignoring duplicate message: {message.id}") - + except Exception as e: debug_full_println(f"[ERROR] Failed to parse message: {e}") - - async def display_message(self, message: BitchatMessage, packet: BitchatPacket, is_private: bool): + + async def display_message( + self, message: BitchatMessage, packet: BitchatPacket, is_private: bool + ): """Display a message in the terminal""" - sender_nick = self.peers.get(packet.sender_id_str, Peer()).nickname or packet.sender_id_str - + sender_nick = ( + self.peers.get(packet.sender_id_str, Peer()).nickname + or packet.sender_id_str + ) + # Track discovered channels if message.channel: self.discovered_channels.add(message.channel) if message.is_encrypted: self.password_protected_channels.add(message.channel) - + # Decrypt channel messages if we have the key display_content = message.content - if message.is_encrypted and message.channel and message.channel in self.channel_keys: + if ( + message.is_encrypted + and message.channel + and message.channel in self.channel_keys + ): try: - creator_fingerprint = self.channel_creators.get(message.channel, '') + creator_fingerprint = self.channel_creators.get(message.channel, "") decrypted = self.encryption_service.decrypt_from_channel( message.encrypted_content, message.channel, self.channel_keys[message.channel], - creator_fingerprint + creator_fingerprint, ) display_content = decrypted except: display_content = "[Encrypted message - decryption failed]" elif message.is_encrypted: display_content = "[Encrypted message - join channel with password]" - + # Check for cover traffic if is_private and display_content.startswith(COVER_TRAFFIC_PREFIX): debug_println(f"[COVER] Discarding dummy message from {sender_nick}") return - + # Update chat context for private messages if is_private: self.chat_context.last_private_sender = (packet.sender_id_str, sender_nick) self.chat_context.add_dm(sender_nick, packet.sender_id_str) - + # Format and display timestamp = datetime.now() display = format_message_display( @@ -809,59 +958,76 @@ async def display_message(self, message: BitchatMessage, packet: BitchatPacket, bool(message.channel), message.channel, self.nickname if is_private else None, - self.nickname + self.nickname, ) - + print(f"\r\033[K{display}") - + if is_private and not isinstance(self.chat_context.current_mode, PrivateDM): print("\033[90m» /reply to respond\033[0m") - - print("> ", end='', flush=True) - + + print("> ", end="", flush=True) + async def handle_fragment(self, packet: BitchatPacket, raw_data: bytes): """Handle message fragment""" if len(packet.payload) >= 13: fragment_id = packet.payload[0:8] - index = struct.unpack('>H', packet.payload[8:10])[0] - total = struct.unpack('>H', packet.payload[10:12])[0] + index = struct.unpack(">H", packet.payload[8:10])[0] + total = struct.unpack(">H", packet.payload[10:12])[0] original_type = packet.payload[12] fragment_data = packet.payload[13:] - + result = self.fragment_collector.add_fragment( - fragment_id, index, total, original_type, fragment_data, packet.sender_id_str + fragment_id, + index, + total, + original_type, + fragment_data, + packet.sender_id_str, ) - + if result: complete_data, _ = result reassembled_packet = parse_bitchat_packet(complete_data) await self.handle_packet(reassembled_packet, complete_data) - + # Relay fragment if TTL > 1 if packet.ttl > 1: await asyncio.sleep(random.uniform(0.01, 0.05)) relay_data = bytearray(raw_data) relay_data[2] = packet.ttl - 1 await self.send_packet(bytes(relay_data)) - + async def handle_key_exchange(self, packet: BitchatPacket): """Handle key exchange""" try: # Convert bytearray to bytes for encryption service - payload_bytes = bytes(packet.payload) if isinstance(packet.payload, bytearray) else packet.payload - response = self.encryption_service.process_handshake_message(packet.sender_id_str, payload_bytes) + payload_bytes = ( + bytes(packet.payload) + if isinstance(packet.payload, bytearray) + else packet.payload + ) + response = self.encryption_service.process_handshake_message( + packet.sender_id_str, payload_bytes + ) if response: response_packet = create_bitchat_packet( self.my_peer_id, MessageType.KEY_EXCHANGE, response ) await self.send_packet(response_packet) - + if self.encryption_service.is_session_established(packet.sender_id_str): - debug_println(f"[CRYPTO] Handshake completed with {packet.sender_id_str}") + debug_println( + f"[CRYPTO] Handshake completed with {packet.sender_id_str}" + ) # If this is a new peer after reconnection, send our key exchange too if packet.sender_id_str not in self.peers: - debug_println(f"[CRYPTO] Sending key exchange response to new peer {packet.sender_id_str}") - handshake_message = self.encryption_service.initiate_handshake(packet.sender_id_str) + debug_println( + f"[CRYPTO] Sending key exchange response to new peer {packet.sender_id_str}" + ) + handshake_message = self.encryption_service.initiate_handshake( + packet.sender_id_str + ) key_exchange_packet = create_bitchat_packet( self.my_peer_id, MessageType.KEY_EXCHANGE, handshake_message ) @@ -869,259 +1035,389 @@ async def handle_key_exchange(self, packet: BitchatPacket): except Exception as e: debug_println(f"[CRYPTO] Handshake failed with {packet.sender_id_str}: {e}") - + async def handle_noise_handshake_init(self, packet: BitchatPacket): """Handle Noise handshake initiation""" debug_println(f"[NOISE] Received handshake init from {packet.sender_id_str}") - debug_println(f"[NOISE] Recipient ID: {packet.recipient_id_str}, My ID: {self.my_peer_id}") - + debug_println( + f"[NOISE] Recipient ID: {packet.recipient_id_str}, My ID: {self.my_peer_id}" + ) + # Check if this handshake is for us if packet.recipient_id_str and packet.recipient_id_str != self.my_peer_id: debug_println(f"[NOISE] Handshake not for us, ignoring") return - - # Check payload size + + # Check payload size payload_size = len(packet.payload) debug_println(f"[NOISE] Handshake payload size: {payload_size} bytes") debug_println(f"[NOISE] Handshake payload hex: {packet.payload.hex()[:64]}...") - + try: # Convert bytearray to bytes for encryption service - payload_bytes = bytes(packet.payload) if isinstance(packet.payload, bytearray) else packet.payload - response = self.encryption_service.process_handshake_message(packet.sender_id_str, payload_bytes) - debug_println(f"[NOISE] process_handshake_message returned: {bool(response)}, response size: {len(response) if response else 0}") - + payload_bytes = ( + bytes(packet.payload) + if isinstance(packet.payload, bytearray) + else packet.payload + ) + response = self.encryption_service.process_handshake_message( + packet.sender_id_str, payload_bytes + ) + debug_println( + f"[NOISE] process_handshake_message returned: {bool(response)}, response size: {len(response) if response else 0}" + ) + if response: # Send handshake response with proper recipient response_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, packet.sender_id_str, MessageType.NOISE_HANDSHAKE_RESP, response, None + self.my_peer_id, + packet.sender_id_str, + MessageType.NOISE_HANDSHAKE_RESP, + response, + None, ) # Set TTL to 3 like iOS response_data = bytearray(response_packet) response_data[2] = 3 await self.send_packet(bytes(response_data)) - debug_println(f"[NOISE] Sent handshake response to {packet.sender_id_str}, payload size: {len(response)}") - + debug_println( + f"[NOISE] Sent handshake response to {packet.sender_id_str}, payload size: {len(response)}" + ) + if self.encryption_service.is_session_established(packet.sender_id_str): - debug_println(f"[NOISE] Handshake completed with {packet.sender_id_str}") + debug_println( + f"[NOISE] Handshake completed with {packet.sender_id_str}" + ) # Clear handshake attempt time on success (matching Swift) self.handshake_attempt_times.pop(packet.sender_id_str, None) - peer_nickname = self.peers.get(packet.sender_id_str, Peer()).nickname or packet.sender_id_str - print(f"\r\033[K\033[92m✓ Secure session established with {peer_nickname}\033[0m") - print("> ", end='', flush=True) + peer_nickname = ( + self.peers.get(packet.sender_id_str, Peer()).nickname + or packet.sender_id_str + ) + print( + f"\r\033[K\033[92m✓ Secure session established with {peer_nickname}\033[0m" + ) + print("> ", end="", flush=True) # Add small delay before sending pending messages to avoid BLE congestion await asyncio.sleep(0.1) # Send any pending private messages await self.send_pending_private_messages(packet.sender_id_str) - + except Exception as e: - debug_println(f"[NOISE] Handshake init failed with {packet.sender_id_str}: {e}") + debug_println( + f"[NOISE] Handshake init failed with {packet.sender_id_str}: {e}" + ) import traceback + debug_println(f"[NOISE] Handshake error details: {traceback.format_exc()}") # Clear any partial handshake state self.encryption_service.clear_handshake_state(packet.sender_id_str) - + async def handle_noise_handshake_resp(self, packet: BitchatPacket): """Handle Noise handshake response""" - debug_println(f"[NOISE] Received handshake response from {packet.sender_id_str}") - debug_println(f"[NOISE] Recipient ID: {packet.recipient_id_str}, My ID: {self.my_peer_id}") - + debug_println( + f"[NOISE] Received handshake response from {packet.sender_id_str}" + ) + debug_println( + f"[NOISE] Recipient ID: {packet.recipient_id_str}, My ID: {self.my_peer_id}" + ) + # Check if this handshake response is for us if packet.recipient_id_str and packet.recipient_id_str != self.my_peer_id: debug_println(f"[NOISE] Handshake response not for us, ignoring") return - + payload_size = len(packet.payload) debug_println(f"[NOISE] Handshake response payload size: {payload_size} bytes") - debug_println(f"[NOISE] Handshake response payload hex: {packet.payload.hex()[:64]}...") - + debug_println( + f"[NOISE] Handshake response payload hex: {packet.payload.hex()[:64]}..." + ) + try: # Convert bytearray to bytes for encryption service - payload_bytes = bytes(packet.payload) if isinstance(packet.payload, bytearray) else packet.payload - response = self.encryption_service.process_handshake_message(packet.sender_id_str, payload_bytes) - debug_println(f"[NOISE] process_handshake_message returned: {bool(response)}, response size: {len(response) if response else 0}") - + payload_bytes = ( + bytes(packet.payload) + if isinstance(packet.payload, bytearray) + else packet.payload + ) + response = self.encryption_service.process_handshake_message( + packet.sender_id_str, payload_bytes + ) + debug_println( + f"[NOISE] process_handshake_message returned: {bool(response)}, response size: {len(response) if response else 0}" + ) + if response: # Send final handshake message final_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, packet.sender_id_str, MessageType.NOISE_HANDSHAKE_INIT, response, None # Continue with same type + self.my_peer_id, + packet.sender_id_str, + MessageType.NOISE_HANDSHAKE_INIT, + response, + None, # Continue with same type ) # Set TTL to 3 like iOS final_data = bytearray(final_packet) final_data[2] = 3 await self.send_packet(bytes(final_data)) - debug_println(f"[NOISE] Sent final handshake message to {packet.sender_id_str}, payload size: {len(response)}") - + debug_println( + f"[NOISE] Sent final handshake message to {packet.sender_id_str}, payload size: {len(response)}" + ) + if self.encryption_service.is_session_established(packet.sender_id_str): - debug_println(f"[NOISE] Handshake completed with {packet.sender_id_str}") + debug_println( + f"[NOISE] Handshake completed with {packet.sender_id_str}" + ) # Clear handshake attempt time on success (matching Swift) self.handshake_attempt_times.pop(packet.sender_id_str, None) - peer_nickname = self.peers.get(packet.sender_id_str, Peer()).nickname or packet.sender_id_str - print(f"\r\033[K\033[92m✓ Secure session established with {peer_nickname}\033[0m") - print("> ", end='', flush=True) + peer_nickname = ( + self.peers.get(packet.sender_id_str, Peer()).nickname + or packet.sender_id_str + ) + print( + f"\r\033[K\033[92m✓ Secure session established with {peer_nickname}\033[0m" + ) + print("> ", end="", flush=True) # Add small delay before sending pending messages to avoid BLE congestion await asyncio.sleep(0.1) # Send any pending private messages await self.send_pending_private_messages(packet.sender_id_str) - + except Exception as e: - debug_println(f"[NOISE] Handshake response failed with {packet.sender_id_str}: {e}") + debug_println( + f"[NOISE] Handshake response failed with {packet.sender_id_str}: {e}" + ) import traceback + debug_println(f"[NOISE] Handshake error details: {traceback.format_exc()}") # Clear any partial handshake state self.encryption_service.clear_handshake_state(packet.sender_id_str) - + async def handle_noise_encrypted(self, packet: BitchatPacket, raw_data: bytes): """Handle Noise encrypted message""" debug_println(f"[NOISE] Received encrypted message from {packet.sender_id_str}") - + # Check if sender is blocked fingerprint = self.encryption_service.get_peer_fingerprint(packet.sender_id_str) if fingerprint and fingerprint in self.blocked_peers: - debug_println(f"[BLOCKED] Ignoring encrypted message from blocked peer: {packet.sender_id_str}") + debug_println( + f"[BLOCKED] Ignoring encrypted message from blocked peer: {packet.sender_id_str}" + ) return - + try: # Convert bytearray to bytes for encryption service - payload_bytes = bytes(packet.payload) if isinstance(packet.payload, bytearray) else packet.payload - + payload_bytes = ( + bytes(packet.payload) + if isinstance(packet.payload, bytearray) + else packet.payload + ) + # Decrypt the Noise encrypted payload using the improved method - decrypted_payload = self.encryption_service.decrypt_from_peer(packet.sender_id_str, payload_bytes) - debug_println(f"[NOISE] Successfully decrypted {len(decrypted_payload)} bytes from {packet.sender_id_str}") - + decrypted_payload = self.encryption_service.decrypt_from_peer( + packet.sender_id_str, payload_bytes + ) + debug_println( + f"[NOISE] Successfully decrypted {len(decrypted_payload)} bytes from {packet.sender_id_str}" + ) + # The decrypted payload should be a complete BitchatPacket (matching Swift implementation) # Swift creates: BitchatPacket(type: MessageType.message, ...) and encrypts the whole packet - + try: # Check if the decrypted data starts with version 1 (BitchatPacket) if len(decrypted_payload) > 0 and decrypted_payload[0] == 1: # Parse the decrypted data as a complete BitchatPacket inner_packet = parse_bitchat_packet(decrypted_payload) if inner_packet: - debug_println(f"[NOISE] Decrypted inner packet: type={inner_packet.msg_type.name if hasattr(inner_packet.msg_type, 'name') else inner_packet.msg_type}, sender={inner_packet.sender_id_str}") - + debug_println( + f"[NOISE] Decrypted inner packet: type={inner_packet.msg_type.name if hasattr(inner_packet.msg_type, 'name') else inner_packet.msg_type}, sender={inner_packet.sender_id_str}" + ) + # Verify this is a MESSAGE packet (as created by Swift) if inner_packet.msg_type == MessageType.MESSAGE: # Parse the message payload from the inner packet try: - message = parse_bitchat_message_payload(inner_packet.payload) - + message = parse_bitchat_message_payload( + inner_packet.payload + ) + # Check for duplicates if message.id not in self.processed_messages: self.bloom.add(message.id) self.processed_messages.add(message.id) - + # Display the message as private await self.display_message(message, packet, True) - + # Send ACK - await self.send_delivery_ack(message.id, packet.sender_id_str, True) + await self.send_delivery_ack( + message.id, packet.sender_id_str, True + ) else: - debug_println(f"[DUPLICATE] Ignoring duplicate encrypted message: {message.id}") - + debug_println( + f"[DUPLICATE] Ignoring duplicate encrypted message: {message.id}" + ) + except Exception as e: - debug_println(f"[NOISE] Failed to parse inner message payload: {e}") + debug_println( + f"[NOISE] Failed to parse inner message payload: {e}" + ) else: - debug_println(f"[NOISE] Unexpected inner packet type: {inner_packet.msg_type}, expected MESSAGE") + debug_println( + f"[NOISE] Unexpected inner packet type: {inner_packet.msg_type}, expected MESSAGE" + ) # Handle other types of inner packets if needed await self.handle_packet(inner_packet, decrypted_payload) else: - debug_println(f"[NOISE] Failed to parse decrypted data as BitchatPacket") + debug_println( + f"[NOISE] Failed to parse decrypted data as BitchatPacket" + ) else: # Handle non-BitchatPacket data (likely JSON acknowledgments or receipts) - debug_println(f"[NOISE] Decrypted data does not start with version 1, likely acknowledgment/receipt") + debug_println( + f"[NOISE] Decrypted data does not start with version 1, likely acknowledgment/receipt" + ) try: # Try to parse as JSON (iOS read receipts/acks start with newline + JSON) - data_str = decrypted_payload.decode('utf-8').strip() - if data_str.startswith('{') and data_str.endswith('}'): + data_str = decrypted_payload.decode("utf-8").strip() + if data_str.startswith("{") and data_str.endswith("}"): import json + ack_data = json.loads(data_str) - debug_println(f"[NOISE] Received acknowledgment: {ack_data}") + debug_println( + f"[NOISE] Received acknowledgment: {ack_data}" + ) # Handle acknowledgment data if needed else: debug_println(f"[NOISE] Unknown decrypted data format") except Exception as json_e: - debug_println(f"[NOISE] Failed to parse as JSON acknowledgment: {json_e}") - + debug_println( + f"[NOISE] Failed to parse as JSON acknowledgment: {json_e}" + ) + except Exception as e: debug_println(f"[NOISE] Error parsing decrypted inner packet: {e}") # Log the first few bytes for debugging - preview = decrypted_payload[:50] if len(decrypted_payload) >= 50 else decrypted_payload - debug_println(f"[NOISE] Decrypted data preview: {preview.hex() if isinstance(preview, bytes) else preview}") - + preview = ( + decrypted_payload[:50] + if len(decrypted_payload) >= 50 + else decrypted_payload + ) + debug_println( + f"[NOISE] Decrypted data preview: {preview.hex() if isinstance(preview, bytes) else preview}" + ) + except Exception as e: - debug_println(f"[NOISE] Failed to decrypt message from {packet.sender_id_str}: {e}") + debug_println( + f"[NOISE] Failed to decrypt message from {packet.sender_id_str}: {e}" + ) # Check if we have a session with this peer if not self.encryption_service.is_session_established(packet.sender_id_str): - debug_println(f"[NOISE] No session established with {packet.sender_id_str}") + debug_println( + f"[NOISE] No session established with {packet.sender_id_str}" + ) else: - debug_println(f"[NOISE] Session exists but decryption failed - possible key sync issue") + debug_println( + f"[NOISE] Session exists but decryption failed - possible key sync issue" + ) # If it's an InvalidTag error, it might be a nonce sync issue if "InvalidTag" in str(e): - debug_println(f"[NOISE] InvalidTag suggests nonce desync - this could be from iOS sending acknowledgments") + debug_println( + f"[NOISE] InvalidTag suggests nonce desync - this could be from iOS sending acknowledgments" + ) # Don't reset the session here, just log it # The nonce is already incremented by the failed decrypt attempt - + async def handle_leave(self, packet: BitchatPacket): """Handle leave notification""" - payload_str = packet.payload.decode('utf-8', errors='ignore').strip() - - if payload_str.startswith('#'): + payload_str = packet.payload.decode("utf-8", errors="ignore").strip() + + if payload_str.startswith("#"): # Channel leave channel = payload_str - sender_nick = self.peers.get(packet.sender_id_str, Peer()).nickname or packet.sender_id_str - - if isinstance(self.chat_context.current_mode, Channel) and \ - self.chat_context.current_mode.name == channel: - print(f"\r\033[K\033[90m« {sender_nick} left {channel}\033[0m\n> ", end='', flush=True) - + sender_nick = ( + self.peers.get(packet.sender_id_str, Peer()).nickname + or packet.sender_id_str + ) + + if ( + isinstance(self.chat_context.current_mode, Channel) + and self.chat_context.current_mode.name == channel + ): + print( + f"\r\033[K\033[90m« {sender_nick} left {channel}\033[0m\n> ", + end="", + flush=True, + ) + debug_println(f"[<-- RECV] {sender_nick} left channel {channel}") else: # Peer disconnect disconnected_peer = self.peers.pop(packet.sender_id_str, None) if disconnected_peer and disconnected_peer.nickname: - print(f"\r\033[K\033[33m{disconnected_peer.nickname} disconnected\033[0m\n> ", end='', flush=True) - + print( + f"\r\033[K\033[33m{disconnected_peer.nickname} disconnected\033[0m\n> ", + end="", + flush=True, + ) + # Remove from active DMs if disconnected_peer.nickname in self.chat_context.active_dms: del self.chat_context.active_dms[disconnected_peer.nickname] - + # Clear pending messages for this peer if packet.sender_id_str in self.pending_private_messages: del self.pending_private_messages[packet.sender_id_str] - + # Clear encryption session for this peer self.encryption_service.remove_session(packet.sender_id_str) - debug_println(f"[NOISE] Cleared session for disconnected peer {packet.sender_id_str}") - + debug_println( + f"[NOISE] Cleared session for disconnected peer {packet.sender_id_str}" + ) + # If we're in a DM with this peer, switch to public - if isinstance(self.chat_context.current_mode, PrivateDM) and \ - self.chat_context.current_mode.peer_id == packet.sender_id_str: + if ( + isinstance(self.chat_context.current_mode, PrivateDM) + and self.chat_context.current_mode.peer_id == packet.sender_id_str + ): self.chat_context.switch_to_public() - print("\033[90m» Switched to public chat (peer disconnected)\033[0m\n> ", end='', flush=True) - - debug_println(f"[<-- RECV] Peer {packet.sender_id_str} ({payload_str}) has left") - + print( + "\033[90m» Switched to public chat (peer disconnected)\033[0m\n> ", + end="", + flush=True, + ) + + debug_println( + f"[<-- RECV] Peer {packet.sender_id_str} ({payload_str}) has left" + ) + # If this was the last peer, we might be alone now if len(self.peers) == 0: - print("\033[90m» You're now the only one in the network.\033[0m\n> ", end='', flush=True) - + print( + "\033[90m» You're now the only one in the network.\033[0m\n> ", + end="", + flush=True, + ) + async def handle_channel_announce(self, packet: BitchatPacket): """Handle channel announcement""" - payload_str = packet.payload.decode('utf-8', errors='ignore') - parts = payload_str.split('|') - + payload_str = packet.payload.decode("utf-8", errors="ignore") + parts = payload_str.split("|") + if len(parts) >= 3: channel = parts[0] - is_protected = parts[1] == '1' + is_protected = parts[1] == "1" creator_id = parts[2] key_commitment = parts[3] if len(parts) > 3 else "" - - debug_println(f"[<-- RECV] Channel announce: {channel} (protected: {is_protected}, owner: {creator_id})") - + + debug_println( + f"[<-- RECV] Channel announce: {channel} (protected: {is_protected}, owner: {creator_id})" + ) + if creator_id: self.channel_creators[channel] = creator_id - + if is_protected: self.password_protected_channels.add(channel) if key_commitment: @@ -1130,41 +1426,53 @@ async def handle_channel_announce(self, packet: BitchatPacket): self.password_protected_channels.discard(channel) self.channel_keys.pop(channel, None) self.channel_key_commitments.pop(channel, None) - + self.chat_context.add_channel(channel) await self.save_app_state() - + async def handle_delivery_ack(self, packet: BitchatPacket, raw_data: bytes): """Handle delivery acknowledgment""" - is_for_us = packet.recipient_id_str == self.my_peer_id if packet.recipient_id_str else False - + is_for_us = ( + packet.recipient_id_str == self.my_peer_id + if packet.recipient_id_str + else False + ) + if is_for_us: # Decrypt if needed ack_payload = packet.payload - if packet.ttl == 3 and self.encryption_service.is_session_established(packet.sender_id_str): + if packet.ttl == 3 and self.encryption_service.is_session_established( + packet.sender_id_str + ): try: - ack_payload = self.encryption_service.decrypt_from_peer(packet.sender_id_str, packet.payload) + ack_payload = self.encryption_service.decrypt_from_peer( + packet.sender_id_str, packet.payload + ) except: pass - + # Parse ACK try: ack_data = json.loads(ack_payload) ack = DeliveryAck( - ack_data['originalMessageID'], - ack_data['ackID'], - ack_data['recipientID'], - ack_data['recipientNickname'], - ack_data['timestamp'], - ack_data['hopCount'] + ack_data["originalMessageID"], + ack_data["ackID"], + ack_data["recipientID"], + ack_data["recipientNickname"], + ack_data["timestamp"], + ack_data["hopCount"], ) - + if self.delivery_tracker.mark_delivered(ack.original_message_id): - print(f"\r\u001b[K\u001b[90m✓ Delivered to {ack.recipient_nickname}\u001b[0m\n> ", end='', flush=True) - + print( + f"\r\u001b[K\u001b[90m✓ Delivered to {ack.recipient_nickname}\u001b[0m\n> ", + end="", + flush=True, + ) + except Exception as e: debug_println(f"[ACK] Failed to parse delivery ACK: {e}") - + elif packet.ttl > 1: # Relay ACK relay_data = bytearray(raw_data) @@ -1176,65 +1484,85 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): try: sender_id = packet.sender_id_str debug_println(f"[NOISE] Received identity announcement from {sender_id}") - + # Skip if it's from ourselves if sender_id == self.my_peer_id: return - + # Try to decode the identity announcement announcement = None - + # First try binary format, then JSON fallback try: - announcement = self.parse_noise_identity_announcement_binary(packet.payload) + announcement = self.parse_noise_identity_announcement_binary( + packet.payload + ) except Exception as be: debug_println(f"[NOISE] Binary decode failed: {be}") # Try JSON fallback for compatibility try: - announcement_data = json.loads(packet.payload.decode('utf-8')) + announcement_data = json.loads(packet.payload.decode("utf-8")) announcement = { - 'peerID': announcement_data.get('peerID', sender_id), - 'nickname': announcement_data.get('nickname', 'Unknown'), - 'publicKey': announcement_data.get('publicKey', ''), - 'signingPublicKey': announcement_data.get('signingPublicKey', ''), - 'timestamp': announcement_data.get('timestamp', 0), - 'signature': announcement_data.get('signature', '') + "peerID": announcement_data.get("peerID", sender_id), + "nickname": announcement_data.get("nickname", "Unknown"), + "publicKey": announcement_data.get("publicKey", ""), + "signingPublicKey": announcement_data.get( + "signingPublicKey", "" + ), + "timestamp": announcement_data.get("timestamp", 0), + "signature": announcement_data.get("signature", ""), } except Exception as je: debug_println(f"[NOISE] JSON decode also failed: {je}") - debug_println(f"[NOISE] Raw payload (first 32 bytes): {packet.payload[:32].hex()}") + debug_println( + f"[NOISE] Raw payload (first 32 bytes): {packet.payload[:32].hex()}" + ) return - + if not announcement: - debug_println(f"[NOISE] Failed to decode identity announcement from {sender_id}") + debug_println( + f"[NOISE] Failed to decode identity announcement from {sender_id}" + ) return - - peer_id = announcement['peerID'] - nickname = announcement['nickname'] - + + peer_id = announcement["peerID"] + nickname = announcement["nickname"] + debug_println(f"[NOISE] Identity announcement: {peer_id} -> {nickname}") - + # Check if this is a new peer is_new_peer = peer_id not in self.peers - + # Update peer info if peer_id not in self.peers: self.peers[peer_id] = Peer() self.peers[peer_id].nickname = nickname - + if is_new_peer: - print(f"\r\033[K\033[33m{nickname} connected\033[0m\n> ", end='', flush=True) - debug_println(f"[<-- RECV] Announce: Peer {peer_id} is now known as '{nickname}'") - + print( + f"\r\033[K\033[33m{nickname} connected\033[0m\n> ", + end="", + flush=True, + ) + debug_println( + f"[<-- RECV] Announce: Peer {peer_id} is now known as '{nickname}'" + ) + # Check if we should initiate handshake (lexicographic comparison) if self.my_peer_id < peer_id: debug_println(f"[NOISE] We should initiate handshake with {peer_id}") # Check if we already have a session or ongoing handshake if not self.encryption_service.is_session_established(peer_id): try: - handshake_message = self.encryption_service.initiate_handshake(peer_id) + handshake_message = self.encryption_service.initiate_handshake( + peer_id + ) handshake_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, peer_id, MessageType.NOISE_HANDSHAKE_INIT, handshake_message, None + self.my_peer_id, + peer_id, + MessageType.NOISE_HANDSHAKE_INIT, + handshake_message, + None, ) # Set TTL to 3 like iOS handshake_data = bytearray(handshake_packet) @@ -1246,20 +1574,25 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): debug_println(f"[NOISE] Failed to initiate handshake: {e}") else: debug_println(f"[NOISE] Waiting for {peer_id} to initiate handshake") - + except Exception as e: debug_println(f"[NOISE] Error handling identity announcement: {e}") import traceback - debug_println(f"[NOISE] Identity announce error details: {traceback.format_exc()}") - + + debug_println( + f"[NOISE] Identity announce error details: {traceback.format_exc()}" + ) + def parse_noise_identity_announcement_binary(self, data: bytes) -> dict: """Parse binary format noise identity announcement matching iOS appendData format""" try: offset = 0 - - debug_println(f"[NOISE] Parsing binary announcement, total length: {len(data)}") + + debug_println( + f"[NOISE] Parsing binary announcement, total length: {len(data)}" + ) debug_println(f"[NOISE] Raw data (hex): {data.hex()}") - + # Read flags byte if offset >= len(data): debug_println("[NOISE] Error: Not enough data for flags") @@ -1267,47 +1600,59 @@ def parse_noise_identity_announcement_binary(self, data: bytes) -> dict: flags = data[offset] offset += 1 debug_println(f"[NOISE] Flags: 0x{flags:02x}") - + # Check if previousPeerID is present (flag bit 0) has_previous_peer_id = (flags & 0x01) != 0 debug_println(f"[NOISE] Has previous peer ID: {has_previous_peer_id}") - + # Read peerID (8 bytes) if offset + 8 > len(data): - debug_println(f"[NOISE] Error: Not enough data for peerID, need 8 bytes, have {len(data) - offset}") + debug_println( + f"[NOISE] Error: Not enough data for peerID, need 8 bytes, have {len(data) - offset}" + ) return None - peer_id = data[offset:offset+8].hex() + peer_id = data[offset : offset + 8].hex() offset += 8 debug_println(f"[NOISE] Peer ID: {peer_id}") - + # Read publicKey using appendData format (1-byte length prefix for 255 max) if offset >= len(data): debug_println("[NOISE] Error: Not enough data for publicKey length") return None pub_key_len = data[offset] offset += 1 - + if offset + pub_key_len > len(data): - debug_println(f"[NOISE] Error: Not enough data for publicKey, need {pub_key_len} bytes, have {len(data) - offset}") + debug_println( + f"[NOISE] Error: Not enough data for publicKey, need {pub_key_len} bytes, have {len(data) - offset}" + ) return None - public_key = data[offset:offset+pub_key_len] + public_key = data[offset : offset + pub_key_len] offset += pub_key_len - debug_println(f"[NOISE] Public key length: {pub_key_len}, key: {public_key.hex()}") - + debug_println( + f"[NOISE] Public key length: {pub_key_len}, key: {public_key.hex()}" + ) + # Read signingPublicKey using appendData format (1-byte length prefix for 255 max) if offset >= len(data): - debug_println("[NOISE] Error: Not enough data for signingPublicKey length") + debug_println( + "[NOISE] Error: Not enough data for signingPublicKey length" + ) return None signing_key_len = data[offset] offset += 1 - + if offset + signing_key_len > len(data): - debug_println(f"[NOISE] Error: Not enough data for signingPublicKey, need {signing_key_len} bytes, have {len(data) - offset}") + debug_println( + f"[NOISE] Error: Not enough data for signingPublicKey, need {signing_key_len} bytes, have {len(data) - offset}" + ) return None - signing_public_key = data[offset:offset+signing_key_len] + signing_public_key = data[offset : offset + signing_key_len] offset += signing_key_len - debug_println(f"[NOISE] Signing public key length: {signing_key_len}, key: {signing_public_key.hex()}") - + debug_println( + f"[NOISE] Signing public key length: {signing_key_len}, key: {signing_public_key.hex()}" + ) + # Read nickname using appendString format (1-byte length prefix for 255 max) if offset >= len(data): debug_println("[NOISE] Error: Not enough data for nickname length") @@ -1315,181 +1660,202 @@ def parse_noise_identity_announcement_binary(self, data: bytes) -> dict: nickname_len = data[offset] offset += 1 debug_println(f"[NOISE] Nickname length: {nickname_len}") - + nickname = "" if nickname_len > 0: if offset + nickname_len > len(data): - debug_println(f"[NOISE] Error: Not enough data for nickname, need {nickname_len} bytes, have {len(data) - offset}") + debug_println( + f"[NOISE] Error: Not enough data for nickname, need {nickname_len} bytes, have {len(data) - offset}" + ) return None - nickname_bytes = data[offset:offset+nickname_len] + nickname_bytes = data[offset : offset + nickname_len] offset += nickname_len - nickname = nickname_bytes.decode('utf-8') + nickname = nickname_bytes.decode("utf-8") debug_println(f"[NOISE] Nickname: '{nickname}'") else: debug_println("[NOISE] Nickname: (empty)") - + # Read timestamp using appendDate format (8-byte UInt64 in milliseconds, big-endian) if offset + 8 > len(data): - debug_println(f"[NOISE] Error: Not enough data for timestamp, need 8 bytes, have {len(data) - offset}") + debug_println( + f"[NOISE] Error: Not enough data for timestamp, need 8 bytes, have {len(data) - offset}" + ) return None - timestamp_ms = int.from_bytes(data[offset:offset+8], byteorder='big') + timestamp_ms = int.from_bytes(data[offset : offset + 8], byteorder="big") offset += 8 timestamp = timestamp_ms / 1000.0 # Convert from milliseconds to seconds debug_println(f"[NOISE] Timestamp: {timestamp} ({timestamp_ms}ms)") - + # Read previousPeerID if present (8 bytes) previous_peer_id = None if has_previous_peer_id: if offset + 8 > len(data): debug_println("[NOISE] Error: Not enough data for previousPeerID") return None - previous_peer_id = data[offset:offset+8].hex() + previous_peer_id = data[offset : offset + 8].hex() offset += 8 debug_println(f"[NOISE] Previous peer ID: {previous_peer_id}") - + # Read signature using appendData format (1-byte length prefix for 255 max) if offset >= len(data): debug_println("[NOISE] Error: Not enough data for signature length") return None signature_len = data[offset] offset += 1 - + if offset + signature_len > len(data): - debug_println(f"[NOISE] Error: Not enough data for signature, need {signature_len} bytes, have {len(data) - offset}") + debug_println( + f"[NOISE] Error: Not enough data for signature, need {signature_len} bytes, have {len(data) - offset}" + ) return None - signature = data[offset:offset+signature_len] + signature = data[offset : offset + signature_len] offset += signature_len - debug_println(f"[NOISE] Signature length: {signature_len}, sig: {signature.hex()}") - - debug_println(f"[NOISE] Total parsed {offset} bytes out of {len(data)} available") - + debug_println( + f"[NOISE] Signature length: {signature_len}, sig: {signature.hex()}" + ) + + debug_println( + f"[NOISE] Total parsed {offset} bytes out of {len(data)} available" + ) + return { - 'peerID': peer_id, - 'publicKey': public_key.hex(), - 'signingPublicKey': signing_public_key.hex(), - 'nickname': nickname, - 'timestamp': timestamp, - 'signature': signature.hex(), - 'previousPeerID': previous_peer_id, - 'truncated': False + "peerID": peer_id, + "publicKey": public_key.hex(), + "signingPublicKey": signing_public_key.hex(), + "nickname": nickname, + "timestamp": timestamp, + "signature": signature.hex(), + "previousPeerID": previous_peer_id, + "truncated": False, } - + except Exception as e: debug_println(f"[NOISE] Error parsing binary announcement: {e}") import traceback - debug_println(f"[NOISE] Binary parser error details: {traceback.format_exc()}") + + debug_println( + f"[NOISE] Binary parser error details: {traceback.format_exc()}" + ) return None - - def encode_noise_identity_announcement_binary(self, peer_id: str, public_key: bytes, - signing_public_key: bytes, nickname: str, - timestamp: int, signature: bytes, - previous_peer_id: str = None) -> bytes: + + def encode_noise_identity_announcement_binary( + self, + peer_id: str, + public_key: bytes, + signing_public_key: bytes, + nickname: str, + timestamp: int, + signature: bytes, + previous_peer_id: str = None, + ) -> bytes: """Encode noise identity announcement to binary format matching iOS appendData format""" data = bytearray() - + # Flags byte: bit 0 = hasPreviousPeerID flags = 0 if previous_peer_id: flags |= 0x01 data.append(flags) - + # PeerID as 8-byte hex string (match Swift conversion) - peer_data = bytes.fromhex(peer_id.ljust(16, '0')[:16]) # Pad to 8 bytes + peer_data = bytes.fromhex(peer_id.ljust(16, "0")[:16]) # Pad to 8 bytes data.extend(peer_data) - + # PublicKey using appendData format (1-byte length prefix since 32 < 255) data.append(len(public_key)) data.extend(public_key) - + # SigningPublicKey using appendData format (1-byte length prefix since 32 < 255) data.append(len(signing_public_key)) data.extend(signing_public_key) - + # Nickname using appendString format (1-byte length prefix for strings under 255 chars) - nickname_bytes = nickname.encode('utf-8') + nickname_bytes = nickname.encode("utf-8") data.append(len(nickname_bytes)) data.extend(nickname_bytes) - + # Timestamp using appendDate format (8 bytes UInt64 milliseconds, big-endian) timestamp_ms = int(timestamp * 1000) # Convert to milliseconds for i in range(8): - data.append((timestamp_ms >> ((7-i) * 8)) & 0xFF) - + data.append((timestamp_ms >> ((7 - i) * 8)) & 0xFF) + # PreviousPeerID if present (8 bytes, after timestamp) if previous_peer_id: - prev_data = bytes.fromhex(previous_peer_id.ljust(16, '0')[:16]) # Pad to 8 bytes + prev_data = bytes.fromhex( + previous_peer_id.ljust(16, "0")[:16] + ) # Pad to 8 bytes data.extend(prev_data) - + # Signature using appendData format (1-byte length prefix) data.append(len(signature)) data.extend(signature) - + return bytes(data) - - async def send_delivery_ack(self, message_id: str, sender_id: str, is_private: bool): + + async def send_delivery_ack( + self, message_id: str, sender_id: str, is_private: bool + ): """Send delivery acknowledgment""" ack_id = f"{message_id}-{self.my_peer_id}" if not self.delivery_tracker.should_send_ack(ack_id): return - + debug_println(f"[ACK] Sending delivery ACK for message {message_id}") - + ack = DeliveryAck( message_id, str(uuid.uuid4()), self.my_peer_id, self.nickname, int(time.time() * 1000), - 1 + 1, ) - - ack_payload = json.dumps({ - 'originalMessageID': ack.original_message_id, - 'ackID': ack.ack_id, - 'recipientID': ack.recipient_id, - 'recipientNickname': ack.recipient_nickname, - 'timestamp': ack.timestamp, - 'hopCount': ack.hop_count - }).encode() - + + ack_payload = json.dumps( + { + "originalMessageID": ack.original_message_id, + "ackID": ack.ack_id, + "recipientID": ack.recipient_id, + "recipientNickname": ack.recipient_nickname, + "timestamp": ack.timestamp, + "hopCount": ack.hop_count, + } + ).encode() + # Encrypt if private if is_private: try: ack_payload = self.encryption_service.encrypt(ack_payload, sender_id) except: pass - + # Send ACK packet ack_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, - sender_id, - MessageType.DELIVERY_ACK, - ack_payload, - None + self.my_peer_id, sender_id, MessageType.DELIVERY_ACK, ack_payload, None ) - + # Set TTL to 3 ack_packet_data = bytearray(ack_packet) ack_packet_data[2] = 3 - + await self.send_packet(bytes(ack_packet_data)) - - async def send_channel_announce(self, channel: str, is_protected: bool, key_commitment: Optional[str]): + + async def send_channel_announce( + self, channel: str, is_protected: bool, key_commitment: Optional[str] + ): """Send channel announcement""" payload = f"{channel}|{'1' if is_protected else '0'}|{self.my_peer_id}|{key_commitment or ''}" packet = create_bitchat_packet( - self.my_peer_id, - MessageType.CHANNEL_ANNOUNCE, - payload.encode() + self.my_peer_id, MessageType.CHANNEL_ANNOUNCE, payload.encode() ) - + # Set TTL to 5 packet_data = bytearray(packet) packet_data[2] = 5 - + debug_println(f"[CHANNEL] Sending channel announce for {channel}") await self.send_packet(bytes(packet_data)) - + async def save_app_state(self): """Save application state""" self.app_state.nickname = self.nickname @@ -1498,12 +1864,12 @@ async def save_app_state(self): self.app_state.joined_channels = self.chat_context.active_channels self.app_state.password_protected_channels = self.password_protected_channels self.app_state.channel_key_commitments = self.channel_key_commitments - + try: save_state(self.app_state) except Exception as e: logging.error(f"Failed to save state: {e}") - + async def handle_user_input(self, line: str): """Handle user input commands and messages""" # Number switching @@ -1514,12 +1880,12 @@ async def handle_user_input(self, line: str): else: print("» Invalid conversation number") return - + # Commands if line == "/help": print_help() return - + if line == "/exit": # Send leave notification if connected if self.client and self.client.is_connected: @@ -1528,11 +1894,11 @@ async def handle_user_input(self, line: str): ) await self.send_packet(leave_packet) await asyncio.sleep(0.1) # Give time for the packet to send - + await self.save_app_state() self.running = False return - + if line.startswith("/name "): new_name = line[6:].strip() if not new_name: @@ -1541,9 +1907,11 @@ async def handle_user_input(self, line: str): elif len(new_name) > 20: print("\033[93m⚠ Nickname too long\033[0m") print("\033[90mMaximum 20 characters allowed.\033[0m") - elif not all(c.isalnum() or c in '-_' for c in new_name): + elif not all(c.isalnum() or c in "-_" for c in new_name): print("\033[93m⚠ Invalid nickname\033[0m") - print("\033[90mNicknames can only contain letters, numbers, hyphens and underscores.\033[0m") + print( + "\033[90mNicknames can only contain letters, numbers, hyphens and underscores.\033[0m" + ) elif new_name in ["system", "all"]: print("\033[93m⚠ Reserved nickname\033[0m") print("\033[90mThis nickname is reserved and cannot be used.\033[0m") @@ -1556,11 +1924,11 @@ async def handle_user_input(self, line: str): print(f"\033[90m» Nickname changed to: {self.nickname}\033[0m") await self.save_app_state() return - + if line == "/list": self.chat_context.show_conversation_list() return - + if line == "/switch": print(f"\n{self.chat_context.get_conversation_list_with_numbers()}") switch_input = await aioconsole.ainput("Enter number to switch to: ") @@ -1571,16 +1939,16 @@ async def handle_user_input(self, line: str): else: print("» Invalid selection") return - + if line.startswith("/j "): await self.handle_join_channel(line) return - + if line == "/public": self.chat_context.switch_to_public() debug_println(self.chat_context.get_status_line()) return - + if line in ["/online", "/w"]: if not self.client or not self.client.is_connected: print("» You're not connected to any peers yet.") @@ -1591,11 +1959,13 @@ async def handle_user_input(self, line: str): print(f"» Online users: {', '.join(sorted(online_list))}") else: print("» No one else is online right now.") - print("> ", end='', flush=True) + print("> ", end="", flush=True) return - + if line == "/channels": - all_channels = set(self.chat_context.active_channels) | set(self.channel_keys.keys()) + all_channels = set(self.chat_context.active_channels) | set( + self.channel_keys.keys() + ) if all_channels: print("» Discovered channels:") for channel in sorted(all_channels): @@ -1609,19 +1979,25 @@ async def handle_user_input(self, line: str): print(f" {channel}{status}") print("\n✓ = joined, 🔒 = password protected, 🔑 = authenticated") else: - print("» No channels discovered yet. Channels appear as people use them.") - print("> ", end='', flush=True) + print( + "» No channels discovered yet. Channels appear as people use them." + ) + print("> ", end="", flush=True) return - + if line == "/status": peer_count = len(self.peers) channel_count = len(self.chat_context.active_channels) dm_count = len(self.chat_context.active_dms) - connection_status = "Connected" if (self.client and self.client.is_connected) else "Offline" + connection_status = ( + "Connected" if (self.client and self.client.is_connected) else "Offline" + ) session_count = self.encryption_service.get_session_count() pending_handshakes = len(self.encryption_service.handshake_states) - pending_messages = sum(len(msgs) for msgs in self.pending_private_messages.values()) - + pending_messages = sum( + len(msgs) for msgs in self.pending_private_messages.values() + ) + print("\n╭─── Connection Status ──────╮") print(f"│ Status: {connection_status:^18} │") print(f"│ Peers connected: {peer_count:6} │") @@ -1635,48 +2011,56 @@ async def handle_user_input(self, line: str): print(f"│ Your nickname: {self.nickname[:11]:^11} │") print(f"│ Your ID: {self.my_peer_id[:8]}... │") print("╰───────────────────────────╯") - + # Show encryption session details if any if session_count > 0: print("\n🔒 Secure Sessions:") for peer_id in self.encryption_service.get_active_peers(): - nickname = self.peers.get(peer_id, Peer()).nickname or peer_id[:8] + "..." + nickname = ( + self.peers.get(peer_id, Peer()).nickname or peer_id[:8] + "..." + ) fingerprint = self.encryption_service.get_peer_fingerprint(peer_id) - print(f" • {nickname} ({fingerprint[:8] if fingerprint else 'Unknown'}...)") - + print( + f" • {nickname} ({fingerprint[:8] if fingerprint else 'Unknown'}...)" + ) + # Show pending handshakes if any if pending_handshakes > 0: print("\n🤝 Pending Handshakes:") for peer_id in self.encryption_service.handshake_states.keys(): - nickname = self.peers.get(peer_id, Peer()).nickname or peer_id[:8] + "..." + nickname = ( + self.peers.get(peer_id, Peer()).nickname or peer_id[:8] + "..." + ) print(f" • {nickname}") - + # Show pending messages if any if pending_messages > 0: print("\n📝 Queued Messages:") for peer_id, messages in self.pending_private_messages.items(): - nickname = self.peers.get(peer_id, Peer()).nickname or peer_id[:8] + "..." + nickname = ( + self.peers.get(peer_id, Peer()).nickname or peer_id[:8] + "..." + ) print(f" • {len(messages)} message(s) for {nickname}") - - print("> ", end='', flush=True) + + print("> ", end="", flush=True) return - + if line == "/clear": clear_screen() print_banner() mode_name = { ChatMode.Public: "public chat", ChatMode.Channel: f"channel {self.chat_context.current_mode.name}", - ChatMode.PrivateDM: f"DM with {self.chat_context.current_mode.nickname}" + ChatMode.PrivateDM: f"DM with {self.chat_context.current_mode.nickname}", }.get(type(self.chat_context.current_mode), "unknown") print(f"» Cleared {mode_name}") - print("> ", end='', flush=True) + print("> ", end="", flush=True) return - + if line.startswith("/dm "): await self.handle_dm_command(line) return - + if line == "/reply": if self.chat_context.last_private_sender: peer_id, nickname = self.chat_context.last_private_sender @@ -1685,50 +2069,54 @@ async def handle_user_input(self, line: str): else: print("» No private messages received yet.") return - + if line.startswith("/block"): await self.handle_block_command(line) return - + if line.startswith("/unblock "): await self.handle_unblock_command(line) return - + if line == "/leave": await self.handle_leave_command() return - + if line.startswith("/pass "): await self.handle_pass_command(line) return - + if line.startswith("/transfer "): await self.handle_transfer_command(line) return - + # Unknown command if line.startswith("/"): cmd = line.split()[0] print(f"\033[93m⚠ Unknown command: {cmd}\033[0m") print("\033[90mType /help to see available commands.\033[0m") return - + # Regular message - check mode if isinstance(self.chat_context.current_mode, PrivateDM): await self.send_private_message( line, self.chat_context.current_mode.peer_id, - self.chat_context.current_mode.nickname + self.chat_context.current_mode.nickname, ) else: # Check if we're connected before sending if not self.client or not self.client.is_connected: print("\033[93m⚠ You're not connected to any peers yet.\033[0m") - print("\033[90mYour message will be sent once someone joins the network.\033[0m") - print("\033[90m(This Python client doesn't queue messages while offline)\033[0m") + print( + "\033[90mYour message will be sent once someone joins the network.\033[0m" + ) + print( + "\033[90m(This Python client doesn't queue messages while offline)\033[0m" + ) else: await self.send_public_message(line) - + async def handle_join_channel(self, line: str): """Handle /j command""" parts = line.split() @@ -1737,55 +2125,61 @@ async def handle_join_channel(self, line: str): print("\033[90mExample: /j #general\033[0m") print("\033[90mExample: /j #private mysecret\033[0m") return - + channel_name = parts[1] password = parts[2] if len(parts) > 2 else None - + if not channel_name.startswith("#"): print("\033[93m⚠ Channel names must start with #\033[0m") print(f"\033[90mExample: /j #{channel_name}\033[0m") return - + if len(channel_name) > 25: print("\033[93m⚠ Channel name too long\033[0m") print("\033[90mMaximum 25 characters allowed.\033[0m") return - - if not all(c.isalnum() or c in '-_' for c in channel_name[1:]): + + if not all(c.isalnum() or c in "-_" for c in channel_name[1:]): print("\033[93m⚠ Invalid channel name\033[0m") - print("\033[90mChannel names can only contain letters, numbers, hyphens and underscores.\033[0m") + print( + "\033[90mChannel names can only contain letters, numbers, hyphens and underscores.\033[0m" + ) return - + # Check if password protected if channel_name in self.password_protected_channels: if channel_name in self.channel_keys: # We have the key self.discovered_channels.add(channel_name) self.chat_context.switch_to_channel(channel_name) - print("> ", end='', flush=True) + print("> ", end="", flush=True) return - + if not password: - print(f"❌ Channel {channel_name} is password-protected. Use: /j {channel_name} ") + print( + f"❌ Channel {channel_name} is password-protected. Use: /j {channel_name} " + ) return - + if len(password) < 4: print("\033[93m⚠ Password too short\033[0m") print("\033[90mMinimum 4 characters required.\033[0m") return - + key = EncryptionService.derive_channel_key(password, channel_name) - + # Verify password if channel_name in self.channel_key_commitments: test_commitment = hashlib.sha256(key).hexdigest() if test_commitment != self.channel_key_commitments[channel_name]: - print(f"❌ wrong password for channel {channel_name}. please enter the correct password.") + print( + f"❌ wrong password for channel {channel_name}. please enter the correct password." + ) return - + self.channel_keys[channel_name] = key self.discovered_channels.add(channel_name) - + # Save encrypted password if self.app_state.identity_key: try: @@ -1794,17 +2188,19 @@ async def handle_join_channel(self, line: str): await self.save_app_state() except Exception as e: debug_println(f"[CHANNEL] Failed to encrypt password: {e}") - + self.chat_context.switch_to_channel_silent(channel_name) print("\r\033[K\033[90m─────────────────────────\033[0m") - print(f"\033[90m» Joined password-protected channel: {channel_name} 🔒\033[0m") - + print( + f"\033[90m» Joined password-protected channel: {channel_name} 🔒\033[0m" + ) + # Send channel announce if channel_name in self.channel_creators: key_commitment = hashlib.sha256(key).hexdigest() await self.send_channel_announce(channel_name, True, key_commitment) - - print("> ", end='', flush=True) + + print("> ", end="", flush=True) else: # Not password protected if password: @@ -1813,52 +2209,56 @@ async def handle_join_channel(self, line: str): self.discovered_channels.add(channel_name) self.chat_context.switch_to_channel_silent(channel_name) print("\r\033[K\033[90m─────────────────────────\033[0m") - print(f"\033[90m» Joined password-protected channel: {channel_name} 🔒. Just type to send messages.\033[0m") - + print( + f"\033[90m» Joined password-protected channel: {channel_name} 🔒. Just type to send messages.\033[0m" + ) + if channel_name in self.channel_creators: key_commitment = hashlib.sha256(key).hexdigest() await self.send_channel_announce(channel_name, True, key_commitment) - - print("> ", end='', flush=True) + + print("> ", end="", flush=True) else: # Regular channel self.discovered_channels.add(channel_name) - print("\r\033[K", end='') + print("\r\033[K", end="") self.chat_context.switch_to_channel(channel_name) self.channel_keys.pop(channel_name, None) - print("> ", end='', flush=True) - + print("> ", end="", flush=True) + debug_println(self.chat_context.get_status_line()) - + async def handle_dm_command(self, line: str): """Handle /dm command""" if not self.client or not self.client.is_connected: print("\033[93m⚠ Not connected to the BitChat network yet.\033[0m") - print("\033[90mWait for a connection before sending direct messages.\033[0m") + print( + "\033[90mWait for a connection before sending direct messages.\033[0m" + ) return - + parts = line.split(maxsplit=2) - + if len(parts) < 2: print("\033[93m⚠ Usage: /dm [message]\033[0m") print("\033[90mExample: /dm Bob Hey there!\033[0m") return - + target_nickname = parts[1] message = parts[2] if len(parts) > 2 else None - + # Find peer target_peer_id = None for peer_id, peer in self.peers.items(): if peer.nickname == target_nickname: target_peer_id = peer_id break - + if not target_peer_id: print(f"\033[93m⚠ User '{target_nickname}' not found\033[0m") print("\033[90mThey may be offline or using a different nickname.\033[0m") return - + if message: # Send message directly await self.send_private_message(message, target_peer_id, target_nickname) @@ -1866,39 +2266,47 @@ async def handle_dm_command(self, line: str): # Enter DM mode self.chat_context.enter_dm_mode(target_nickname, target_peer_id) debug_println(self.chat_context.get_status_line()) - + async def handle_block_command(self, line: str): """Handle /block command""" parts = line.split() - + if len(parts) == 1: # List blocked if self.blocked_peers: blocked_nicks = [] for peer_id, peer in self.peers.items(): fingerprint = self.encryption_service.get_peer_fingerprint(peer_id) - if fingerprint and fingerprint in self.blocked_peers and peer.nickname: + if ( + fingerprint + and fingerprint in self.blocked_peers + and peer.nickname + ): blocked_nicks.append(peer.nickname) - + if blocked_nicks: print(f"» Blocked peers: {', '.join(blocked_nicks)}") else: - print(f"» Blocked peers (not currently online): {len(self.blocked_peers)}") + print( + f"» Blocked peers (not currently online): {len(self.blocked_peers)}" + ) else: print("» No blocked peers.") elif len(parts) == 2: # Block a peer - target = parts[1].lstrip('@') - + target = parts[1].lstrip("@") + # Find peer target_peer_id = None for peer_id, peer in self.peers.items(): if peer.nickname == target: target_peer_id = peer_id break - + if target_peer_id: - fingerprint = self.encryption_service.get_peer_fingerprint(target_peer_id) + fingerprint = self.encryption_service.get_peer_fingerprint( + target_peer_id + ) if fingerprint: if fingerprint in self.blocked_peers: print(f"» {target} is already blocked.") @@ -1906,34 +2314,38 @@ async def handle_block_command(self, line: str): self.blocked_peers.add(fingerprint) await self.save_app_state() print(f"\n\033[92m✓ Blocked {target}\033[0m") - print(f"\033[90m{target} will no longer be able to send you messages.\033[0m") + print( + f"\033[90m{target} will no longer be able to send you messages.\033[0m" + ) else: print(f"» Cannot block {target}: No identity key received yet.") else: print(f"\033[93m⚠ User '{target}' not found\033[0m") - print("\033[90mThey may be offline or haven't sent any messages yet.\033[0m") + print( + "\033[90mThey may be offline or haven't sent any messages yet.\033[0m" + ) else: print("\033[93m⚠ Usage: /block @\033[0m") print("\033[90mExample: /block @spammer\033[0m") - + async def handle_unblock_command(self, line: str): """Handle /unblock command""" parts = line.split() - + if len(parts) != 2: print("\033[93m⚠ Usage: /unblock @\033[0m") print("\033[90mExample: /unblock @friend\033[0m") return - - target = parts[1].lstrip('@') - + + target = parts[1].lstrip("@") + # Find peer target_peer_id = None for peer_id, peer in self.peers.items(): if peer.nickname == target: target_peer_id = peer_id break - + if target_peer_id: fingerprint = self.encryption_service.get_peer_fingerprint(target_peer_id) if fingerprint: @@ -1948,81 +2360,83 @@ async def handle_unblock_command(self, line: str): print(f"» Cannot unblock {target}: No identity key received.") else: print(f"\033[93m⚠ User '{target}' not found\033[0m") - print("\033[90mThey may be offline or haven't sent any messages yet.\033[0m") - + print( + "\033[90mThey may be offline or haven't sent any messages yet.\033[0m" + ) + async def handle_leave_command(self): """Handle /leave command""" if isinstance(self.chat_context.current_mode, Channel): channel = self.chat_context.current_mode.name - + # Send leave notification leave_payload = channel.encode() leave_packet = create_bitchat_packet( self.my_peer_id, MessageType.LEAVE, leave_payload ) - + # Set TTL to 3 leave_packet_data = bytearray(leave_packet) leave_packet_data[2] = 3 - + await self.send_packet(bytes(leave_packet_data)) - + # Clean up self.channel_keys.pop(channel, None) self.password_protected_channels.discard(channel) self.channel_creators.pop(channel, None) self.channel_key_commitments.pop(channel, None) self.app_state.encrypted_channel_passwords.pop(channel, None) - + self.chat_context.remove_channel(channel) self.chat_context.switch_to_public() - + await self.save_app_state() - + print(f"\033[90m» Left channel {channel}\033[0m") - print("> ", end='', flush=True) + print("> ", end="", flush=True) else: print("» You're not in a channel. Use /j #channel to join one.") - + async def handle_pass_command(self, line: str): """Handle /pass command""" if not isinstance(self.chat_context.current_mode, Channel): print("» You must be in a channel to use /pass.") return - + channel = self.chat_context.current_mode.name parts = line.split(maxsplit=1) - + if len(parts) < 2: print("\033[93m⚠ Usage: /pass \033[0m") print("\033[90mExample: /pass mysecret123\033[0m") return - + new_password = parts[1] - + if len(new_password) < 4: print("\033[93m⚠ Password too short\033[0m") print("\033[90mMinimum 4 characters required.\033[0m") return - + # Check ownership owner = self.channel_creators.get(channel) if owner and owner != self.my_peer_id: print("» Only the channel owner can change the password.") return - + # Claim ownership if no owner if not owner: self.channel_creators[channel] = self.my_peer_id debug_println(f"[CHANNEL] Claiming ownership of {channel}") - + # Update password old_key = self.channel_keys.get(channel) new_key = EncryptionService.derive_channel_key(new_password, channel) - + self.channel_keys[channel] = new_key self.password_protected_channels.add(channel) - + # Save encrypted password if self.app_state.identity_key: try: @@ -2030,127 +2444,171 @@ async def handle_pass_command(self, line: str): self.app_state.encrypted_channel_passwords[channel] = encrypted except Exception as e: debug_println(f"[CHANNEL] Failed to encrypt password: {e}") - + # Calculate commitment commitment_hex = hashlib.sha256(new_key).hexdigest() self.channel_key_commitments[channel] = commitment_hex - + # Send notification with old key if exists if old_key: - notify_msg = "🔐 Password changed by channel owner. Please update your password." + notify_msg = ( + "🔐 Password changed by channel owner. Please update your password." + ) try: - encrypted_notify = self.encryption_service.encrypt_with_key(notify_msg.encode(), old_key) + encrypted_notify = self.encryption_service.encrypt_with_key( + notify_msg.encode(), old_key + ) notify_payload, _ = create_encrypted_channel_message_payload( - self.nickname, notify_msg, channel, old_key, self.encryption_service, self.my_peer_id + self.nickname, + notify_msg, + channel, + old_key, + self.encryption_service, + self.my_peer_id, + ) + notify_packet = create_bitchat_packet( + self.my_peer_id, MessageType.MESSAGE, notify_payload ) - notify_packet = create_bitchat_packet(self.my_peer_id, MessageType.MESSAGE, notify_payload) await self.send_packet(notify_packet) except: pass - + # Send channel announce await self.send_channel_announce(channel, True, commitment_hex) - + # Send init message init_msg = f"🔑 Password {'changed' if old_key else 'set'} | Channel {channel} password {'updated' if old_key else 'protected'} by {self.nickname} | Metadata: {self.my_peer_id.encode().hex()}" init_payload, _ = create_encrypted_channel_message_payload( - self.nickname, init_msg, channel, new_key, self.encryption_service, self.my_peer_id + self.nickname, + init_msg, + channel, + new_key, + self.encryption_service, + self.my_peer_id, + ) + init_packet = create_bitchat_packet( + self.my_peer_id, MessageType.MESSAGE, init_payload ) - init_packet = create_bitchat_packet(self.my_peer_id, MessageType.MESSAGE, init_payload) await self.send_packet(init_packet) - + await self.save_app_state() - + print(f"» Password {'changed' if old_key else 'set'} for {channel}.") print(f"» Members will need to rejoin with: /j {channel} {new_password}") - + async def handle_transfer_command(self, line: str): """Handle /transfer command""" if not isinstance(self.chat_context.current_mode, Channel): print("» You must be in a channel to use /transfer.") return - + channel = self.chat_context.current_mode.name parts = line.split() - + if len(parts) != 2: print("\033[93m⚠ Usage: /transfer @\033[0m") print("\033[90mExample: /transfer @newowner\033[0m") return - + # Check ownership owner_id = self.channel_creators.get(channel) if owner_id != self.my_peer_id: print("» Only the channel owner can transfer ownership.") return - - target = parts[1].lstrip('@') - + + target = parts[1].lstrip("@") + # Find peer new_owner_id = None for peer_id, peer in self.peers.items(): if peer.nickname == target: new_owner_id = peer_id break - + if not new_owner_id: print(f"\033[93m⚠ User '{target}' not found\033[0m") - print("\033[90mMake sure they are online and you have the correct nickname.\033[0m") + print( + "\033[90mMake sure they are online and you have the correct nickname.\033[0m" + ) return - + # Transfer ownership self.channel_creators[channel] = new_owner_id await self.save_app_state() - + # Send announce is_protected = channel in self.password_protected_channels key_commitment = None if is_protected and channel in self.channel_keys: key_commitment = hashlib.sha256(self.channel_keys[channel]).hexdigest() - + await self.send_channel_announce(channel, is_protected, key_commitment) - + print(f"» Transferred ownership of {channel} to {target}") - + async def send_public_message(self, content: str): """Send a public or channel message""" if not self.client or not self.characteristic: print("\033[93m⚠ Not connected to any peers yet.\033[0m") - print("\033[90mYour message will be sent once a connection is established.\033[0m") + print( + "\033[90mYour message will be sent once a connection is established.\033[0m" + ) return - + current_channel = None if isinstance(self.chat_context.current_mode, Channel): current_channel = self.chat_context.current_mode.name - + # Check if password protected - if current_channel in self.password_protected_channels and current_channel not in self.channel_keys: - print(f"❌ Cannot send to password-protected channel {current_channel}. Join with password first.") + if ( + current_channel in self.password_protected_channels + and current_channel not in self.channel_keys + ): + print( + f"❌ Cannot send to password-protected channel {current_channel}. Join with password first." + ) return - + # Create message payload if current_channel and current_channel in self.channel_keys: # Encrypted channel message - creator_fingerprint = self.channel_creators.get(current_channel, '') - encrypted_content = self.encryption_service.encrypt_for_channel(content, current_channel, self.channel_keys[current_channel], creator_fingerprint) + creator_fingerprint = self.channel_creators.get(current_channel, "") + encrypted_content = self.encryption_service.encrypt_for_channel( + content, + current_channel, + self.channel_keys[current_channel], + creator_fingerprint, + ) payload, message_id = create_bitchat_message_payload_full( - self.nickname, content, current_channel, False, self.my_peer_id, True, encrypted_content + self.nickname, + content, + current_channel, + False, + self.my_peer_id, + True, + encrypted_content, ) else: # Regular message payload, message_id = create_bitchat_message_payload_full( - self.nickname, content, current_channel, False, self.my_peer_id, False, None + self.nickname, + content, + current_channel, + False, + self.my_peer_id, + False, + None, ) - + # Track for delivery self.delivery_tracker.track_message(message_id, content, False) - + message_packet = create_bitchat_packet( self.my_peer_id, MessageType.MESSAGE, payload ) - + await self.send_packet(message_packet) - + # Display sent message timestamp = datetime.now() display = format_message_display( @@ -2161,11 +2619,17 @@ async def send_public_message(self, content: str): bool(current_channel), current_channel, None, - self.nickname + self.nickname, ) print(f"\x1b[1A\r\033[K{display}") - - async def send_private_message(self, content: str, target_peer_id: str, target_nickname: str, message_id: Optional[str] = None): + + async def send_private_message( + self, + content: str, + target_peer_id: str, + target_nickname: str, + message_id: Optional[str] = None, + ): """Send a private encrypted message""" if not self.client or not self.characteristic: print("\033[93m⚠ Not connected to any peers yet.\033[0m") @@ -2173,100 +2637,124 @@ async def send_private_message(self, content: str, target_peer_id: str, target_n # Check if we have a Noise session with this peer if not self.encryption_service.is_session_established(target_peer_id): - debug_println(f"[NOISE] No session with {target_peer_id}, need to establish handshake") - + debug_println( + f"[NOISE] No session with {target_peer_id}, need to establish handshake" + ) + # Queue message for sending after handshake completes msg_id = message_id if message_id else str(uuid.uuid4()) if target_peer_id not in self.pending_private_messages: self.pending_private_messages[target_peer_id] = [] - self.pending_private_messages[target_peer_id].append((content, target_nickname, msg_id)) - debug_println(f"[NOISE] Queued private message for {target_peer_id}, {len(self.pending_private_messages[target_peer_id])} messages pending") - + self.pending_private_messages[target_peer_id].append( + (content, target_nickname, msg_id) + ) + debug_println( + f"[NOISE] Queued private message for {target_peer_id}, {len(self.pending_private_messages[target_peer_id])} messages pending" + ) + # Always initiate handshake for private messages since user explicitly requested it - debug_println(f"[NOISE] Initiating handshake with {target_peer_id} for private message") - + debug_println( + f"[NOISE] Initiating handshake with {target_peer_id} for private message" + ) + # Check if we've recently tried to handshake with this peer (matching Swift logic) current_time = time.time() if target_peer_id in self.handshake_attempt_times: last_attempt = self.handshake_attempt_times[target_peer_id] if current_time - last_attempt < self.handshake_timeout: - debug_println(f"[NOISE] Skipping handshake with {target_peer_id} - too recent (last attempt {current_time - last_attempt:.1f}s ago)") - print(f"\033[90m» Handshake already in progress with {target_nickname}, please wait...\033[0m") + debug_println( + f"[NOISE] Skipping handshake with {target_peer_id} - too recent (last attempt {current_time - last_attempt:.1f}s ago)" + ) + print( + f"\033[90m» Handshake already in progress with {target_nickname}, please wait...\033[0m" + ) return - + # Record handshake attempt time self.handshake_attempt_times[target_peer_id] = current_time - + try: - handshake_message = self.encryption_service.initiate_handshake(target_peer_id) + handshake_message = self.encryption_service.initiate_handshake( + target_peer_id + ) handshake_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, target_peer_id, MessageType.NOISE_HANDSHAKE_INIT, handshake_message, None + self.my_peer_id, + target_peer_id, + MessageType.NOISE_HANDSHAKE_INIT, + handshake_message, + None, ) # Set TTL to 3 like iOS handshake_data = bytearray(handshake_packet) handshake_data[2] = 3 handshake_packet = bytes(handshake_data) await self.send_packet(handshake_packet) - debug_println(f"[NOISE] Sent handshake init to {target_peer_id}, payload size: {len(handshake_message)}") + debug_println( + f"[NOISE] Sent handshake init to {target_peer_id}, payload size: {len(handshake_message)}" + ) except Exception as e: debug_println(f"[NOISE] Failed to initiate handshake: {e}") # Clear the attempt time on failure so we can retry sooner self.handshake_attempt_times.pop(target_peer_id, None) - print(f"\033[91m✗ Failed to initiate secure connection with {target_nickname}\033[0m") + print( + f"\033[91m✗ Failed to initiate secure connection with {target_nickname}\033[0m" + ) return - - print(f"\033[90m» Initiating secure handshake with {target_nickname}...\033[0m") - print(f"\033[90m» Your message will be sent automatically once the handshake completes.\033[0m") + + print( + f"\033[90m» Initiating secure handshake with {target_nickname}...\033[0m" + ) + print( + f"\033[90m» Your message will be sent automatically once the handshake completes.\033[0m" + ) return - + debug_println(f"[PRIVATE] Sending encrypted message to {target_nickname}") - + # Create message payload - don't set is_encrypted=True since encryption happens at Noise layer payload, message_id = create_bitchat_message_payload_full( self.nickname, content, None, True, self.my_peer_id, False, None ) - + debug_println(f"[PRIVATE] Created message payload: {len(payload)} bytes") debug_println(f"[PRIVATE] Message payload hex: {payload.hex()}") - + # Track for delivery self.delivery_tracker.track_message(message_id, content, True) - + # Create INNER packet (BitchatPacket with MESSAGE type) that will be encrypted # This matches Swift implementation: BitchatPacket(type: MessageType.message, ...) inner_packet = create_bitchat_packet_with_recipient( - self.my_peer_id, - target_peer_id, - MessageType.MESSAGE, - payload, - None + self.my_peer_id, target_peer_id, MessageType.MESSAGE, payload, None ) - + # Set TTL for inner packet (matching Swift's adaptiveTTL behavior) inner_packet_data = bytearray(inner_packet) inner_packet_data[2] = 7 # TTL for inner packet inner_packet = bytes(inner_packet_data) - + debug_println(f"[PRIVATE] Created inner packet: {len(inner_packet)} bytes") - + try: # Encrypt the ENTIRE inner packet using Noise (matching Swift) - encrypted = self.encryption_service.encrypt_for_peer(target_peer_id, inner_packet) + encrypted = self.encryption_service.encrypt_for_peer( + target_peer_id, inner_packet + ) debug_println(f"[PRIVATE] Encrypted inner packet: {len(encrypted)} bytes") - + # Create outer Noise encrypted packet packet = create_bitchat_packet_with_recipient( self.my_peer_id, target_peer_id, MessageType.NOISE_ENCRYPTED, encrypted, - None + None, ) - + # Send with better error handling for BLE issues try: await self.send_packet(packet) - + # Display sent message timestamp = datetime.now() display = format_message_display( @@ -2277,24 +2765,28 @@ async def send_private_message(self, content: str, target_peer_id: str, target_n False, None, target_nickname, - self.nickname + self.nickname, ) print(f"\x1b[1A\r\033[K{display}") - + except Exception as send_error: # Handle BLE send errors specifically if "could not complete without blocking" in str(send_error): - debug_println(f"[PRIVATE] BLE write blocked, will retry after longer delay") + debug_println( + f"[PRIVATE] BLE write blocked, will retry after longer delay" + ) try: - print(f"\033[90m» Message queued (BLE congestion), retrying...\033[0m") + print( + f"\033[90m» Message queued (BLE congestion), retrying...\033[0m" + ) except BlockingIOError: pass # Ignore even print errors - + # Retry after a longer delay await asyncio.sleep(0.5) try: await self.send_packet(packet) - + # Display sent message on successful retry timestamp = datetime.now() display = format_message_display( @@ -2305,31 +2797,35 @@ async def send_private_message(self, content: str, target_peer_id: str, target_n False, None, target_nickname, - self.nickname + self.nickname, ) print(f"\x1b[1A\r\033[K{display}") debug_println(f"[PRIVATE] Message sent successfully on retry") - + except Exception as retry_error: debug_println(f"[PRIVATE] Retry also failed: {retry_error}") try: - print(f"\033[91m✗ Failed to send message (BLE congestion)\033[0m") + print( + f"\033[91m✗ Failed to send message (BLE congestion)\033[0m" + ) print(f"\033[90m» Try again in a moment\033[0m") except BlockingIOError: pass # Ignore print errors else: # Other errors - re-raise raise send_error - + except Exception as e: debug_println(f"[PRIVATE] Failed to encrypt private message: {e}") - print(f"\033[91m✗ Failed to send encrypted message to {target_nickname}\033[0m") + print( + f"\033[91m✗ Failed to send encrypted message to {target_nickname}\033[0m" + ) print(f"\033[90m» Error: {e}\033[0m") - + async def background_scanner(self): """Background task to scan for peers when not connected""" last_cleanup = time.time() - + while self.running: # Clean up old sessions periodically (every 5 minutes) current_time = time.time() @@ -2337,53 +2833,81 @@ async def background_scanner(self): self.encryption_service.cleanup_old_sessions() last_cleanup = current_time debug_println(f"[CLEANUP] Cleaned up old encryption sessions") - + if not self.client or not self.client.is_connected: # Try to find and connect to a peer device = await self.find_device() if device: - print(f"\r\033[K\033[92m» Found a BitChat device! Connecting...\033[0m") + print( + f"\r\033[K\033[92m» Found a BitChat device! Connecting...\033[0m" + ) try: - self.client = BleakClient(device.address, disconnected_callback=self.handle_disconnect) + self.client = BleakClient( + device.address, disconnected_callback=self.handle_disconnect + ) await self.client.connect() - + # Find characteristic services = self.client.services for service in services: for char in service.characteristics: - if char.uuid.lower() == BITCHAT_CHARACTERISTIC_UUID.lower(): + if ( + char.uuid.lower() + == BITCHAT_CHARACTERISTIC_UUID.lower() + ): self.characteristic = char break if self.characteristic: break - + if self.characteristic: # Subscribe to notifications - await self.client.start_notify(self.characteristic, self.notification_handler) - print(f"\r\033[K\033[92m✓ Connected to BitChat network!\033[0m") - + await self.client.start_notify( + self.characteristic, self.notification_handler + ) + print( + f"\r\033[K\033[92m✓ Connected to BitChat network!\033[0m" + ) + # Clear any stale peers from previous connection self.peers.clear() - + # Send Noise identity announcement try: timestamp_ms = int(time.time() * 1000) - public_key_bytes = self.encryption_service.get_public_key() + public_key_bytes = ( + self.encryption_service.get_public_key() + ) signing_public_key_bytes = self.encryption_service.get_signing_public_key_bytes() - + # Create binding data for signature - timestamp_data = str(timestamp_ms).encode('utf-8') - binding_data = self.my_peer_id.encode('utf-8') + public_key_bytes + timestamp_data - signature = self.encryption_service.sign_data(binding_data) - + timestamp_data = str(timestamp_ms).encode("utf-8") + binding_data = ( + self.my_peer_id.encode("utf-8") + + public_key_bytes + + timestamp_data + ) + signature = self.encryption_service.sign_data( + binding_data + ) + # Encode to binary format - identity_payload = self.encode_noise_identity_announcement_binary( - self.my_peer_id, public_key_bytes, signing_public_key_bytes, - self.nickname, timestamp_ms, signature + identity_payload = ( + self.encode_noise_identity_announcement_binary( + self.my_peer_id, + public_key_bytes, + signing_public_key_bytes, + self.nickname, + timestamp_ms, + signature, + ) ) - + identity_packet = create_bitchat_packet_with_signature( - self.my_peer_id, MessageType.NOISE_IDENTITY_ANNOUNCE, identity_payload, signature + self.my_peer_id, + MessageType.NOISE_IDENTITY_ANNOUNCE, + identity_payload, + signature, ) await self.send_packet(identity_packet) except Exception as e: @@ -2391,26 +2915,30 @@ async def background_scanner(self): # Fallback key_exchange_payload = self.encryption_service.get_combined_public_key_data() key_exchange_packet = create_bitchat_packet( - self.my_peer_id, MessageType.KEY_EXCHANGE, key_exchange_payload + self.my_peer_id, + MessageType.KEY_EXCHANGE, + key_exchange_payload, ) await self.send_packet(key_exchange_packet) - + await asyncio.sleep(0.5) - + announce_packet = create_bitchat_packet( - self.my_peer_id, MessageType.ANNOUNCE, self.nickname.encode() + self.my_peer_id, + MessageType.ANNOUNCE, + self.nickname.encode(), ) await self.send_packet(announce_packet) - - print("> ", end='', flush=True) + + print("> ", end="", flush=True) except Exception as e: debug_println(f"[SCANNER] Connection attempt failed: {e}") self.client = None self.characteristic = None - + # Wait before next scan await asyncio.sleep(5) # Scan every 5 seconds when not connected - + async def input_loop(self): """Handle user input asynchronously""" while self.running: @@ -2422,11 +2950,11 @@ async def input_loop(self): break except Exception as e: debug_println(f"[ERROR] Input error: {e}") - + async def run(self): """Main run loop""" print_banner() - + # Parse command line arguments global DEBUG_LEVEL if "-dd" in sys.argv or "--debug-full" in sys.argv: @@ -2435,18 +2963,18 @@ async def run(self): elif "-d" in sys.argv or "--debug" in sys.argv: DEBUG_LEVEL = DebugLevel.BASIC print("🐛 Debug mode: BASIC (connection info)") - + # Connect to BLE connected = await self.connect() - + # Perform handshake (will work even without connection) await self.handshake() - + # Start background scanner if not connected scanner_task = None if not connected or not self.client: scanner_task = asyncio.create_task(self.background_scanner()) - + # Run input loop try: await self.input_loop() @@ -2455,7 +2983,7 @@ async def run(self): finally: debug_println("\n[+] Disconnecting...") self.running = False - + # Send leave notification if connected if self.client and self.client.is_connected: try: @@ -2466,7 +2994,7 @@ async def run(self): await asyncio.sleep(0.1) # Give time for the packet to send except: pass # Ignore errors during shutdown - + # Cancel background scanner if scanner_task: scanner_task.cancel() @@ -2474,102 +3002,114 @@ async def run(self): await scanner_task except asyncio.CancelledError: pass - + if self.client and self.client.is_connected: await self.client.disconnect() + # Helper functions + def print_banner(): """Print the BitChat banner""" - print("\n\033[38;5;46m##\\ ##\\ ##\\ ##\\ ##\\") + print( + "\n\033[38;5;46m##\\ ##\\ ##\\ ##\\ ##\\" + ) print("## | \\__| ## | ## | ## |") print("#######\\ ##\\ ######\\ #######\\ #######\\ ######\\ ######\\") print("## __##\\ ## |\\_## _| ## _____|## __##\\ \\____##\\\\_## _|") print("## | ## |## | ## | ## / ## | ## | ####### | ## |") print("## | ## |## | ## |##\\ ## | ## | ## |## __## | ## |##\\") print("####### |## | \\#### |\\#######\\ ## | ## |\\####### | \\#### |") - print("\\_______/ \\__| \\____/ \\_______|\\__| \\__| \\_______| \\____/\033[0m") - print("\n\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m") + print( + "\\_______/ \\__| \\____/ \\_______|\\__| \\__| \\_______| \\____/\033[0m" + ) + print( + "\n\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m" + ) print("\033[37mDecentralized • Encrypted • Peer-to-Peer • Open Source\033[0m") print(f"\033[37m bitchat@-python {VERSION} @kaganisildak\033[0m") - print("\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n") + print( + "\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n" + ) + def unpad_packet(data: bytes) -> bytes: """Remove PKCS#7 padding from packet data (matching iOS implementation)""" if len(data) == 0: return data - + # Last byte tells us how much padding to remove padding_length = int(data[-1]) - + # Validate padding (matching iOS logic exactly) if padding_length <= 0 or padding_length > len(data): return data # No padding or invalid padding - + # Remove the indicated number of bytes result = data[:-padding_length] return result + def parse_bitchat_packet(data: bytes) -> BitchatPacket: """Parse a BitChat packet from raw bytes""" HEADER_SIZE = 13 SENDER_ID_SIZE = 8 RECIPIENT_ID_SIZE = 8 - + # Don't remove padding here - we need to parse the header first to know the actual packet size # The iOS client expects properly structured packets with padding intact during parsing - + if len(data) < HEADER_SIZE + SENDER_ID_SIZE: raise ValueError("Packet too small") - + offset = 0 - + # Version version = data[offset] offset += 1 if version != 1: raise ValueError("Unsupported version") - + # Type msg_type = MessageType(data[offset]) offset += 1 - + # TTL ttl = data[offset] offset += 1 - + # Timestamp (skip) offset += 8 - + # Flags flags = data[offset] offset += 1 has_recipient = (flags & FLAG_HAS_RECIPIENT) != 0 has_signature = (flags & FLAG_HAS_SIGNATURE) != 0 is_compressed = (flags & FLAG_IS_COMPRESSED) != 0 - + # Payload length - payload_len = struct.unpack('>H', data[offset:offset+2])[0] + payload_len = struct.unpack(">H", data[offset : offset + 2])[0] offset += 2 - + # Sender ID (trim null bytes) - sender_id_raw = data[offset:offset+SENDER_ID_SIZE] + sender_id_raw = data[offset : offset + SENDER_ID_SIZE] # Remove trailing null bytes - sender_id = sender_id_raw.rstrip(b'\x00') + sender_id = sender_id_raw.rstrip(b"\x00") sender_id_str = sender_id.hex() offset += SENDER_ID_SIZE - + # Recipient ID recipient_id = None recipient_id_str = None if has_recipient: - recipient_id_raw = data[offset:offset+RECIPIENT_ID_SIZE] + recipient_id_raw = data[offset : offset + RECIPIENT_ID_SIZE] # Remove trailing null bytes - recipient_id = recipient_id_raw.rstrip(b'\x00') + recipient_id = recipient_id_raw.rstrip(b"\x00") recipient_id_str = recipient_id.hex() offset += RECIPIENT_ID_SIZE - + # Payload payload_end = offset + payload_len payload = data[offset:payload_end] @@ -2579,126 +3119,164 @@ def parse_bitchat_packet(data: bytes) -> BitchatPacket: signature = None if has_signature: if len(data) >= offset + SIGNATURE_SIZE: - signature = data[offset:offset+SIGNATURE_SIZE] + signature = data[offset : offset + SIGNATURE_SIZE] else: - debug_println(f"[WARN] Packet has signature flag but not enough data for signature.") - + debug_println( + f"[WARN] Packet has signature flag but not enough data for signature." + ) + # Decompress if needed if is_compressed: payload = decompress(payload) - + # Ensure payload is bytes if isinstance(payload, bytearray): payload = bytes(payload) - + return BitchatPacket( - msg_type, sender_id, sender_id_str, - recipient_id, recipient_id_str, payload, ttl + msg_type, sender_id, sender_id_str, recipient_id, recipient_id_str, payload, ttl ) + def parse_bitchat_message_payload(data: bytes) -> BitchatMessage: """Parse message payload, matching Swift implementation""" offset = 0 # 1. Flags - flags = data[offset]; offset += 1 + flags = data[offset] + offset += 1 is_private = (flags & MSG_FLAG_IS_PRIVATE) != 0 has_sender_peer_id = (flags & MSG_FLAG_HAS_SENDER_PEER_ID) != 0 has_channel = (flags & MSG_FLAG_HAS_CHANNEL) != 0 is_encrypted = (flags & MSG_FLAG_IS_ENCRYPTED) != 0 # 2. Timestamp - offset += 8 # Skip timestamp + offset += 8 # Skip timestamp # 3. ID - id_len = data[offset]; offset += 1 - id_str = data[offset:offset+id_len].decode('utf-8'); offset += id_len + id_len = data[offset] + offset += 1 + id_str = data[offset : offset + id_len].decode("utf-8") + offset += id_len # 4. Sender - sender_len = data[offset]; offset += 1 - sender = data[offset:offset+sender_len].decode('utf-8'); offset += sender_len + sender_len = data[offset] + offset += 1 + sender = data[offset : offset + sender_len].decode("utf-8") + offset += sender_len # 5. Content - content_len = struct.unpack('>H', data[offset:offset+2])[0]; offset += 2 - content_bytes = data[offset:offset+content_len]; offset += content_len + content_len = struct.unpack(">H", data[offset : offset + 2])[0] + offset += 2 + content_bytes = data[offset : offset + content_len] + offset += content_len content = "" encrypted_content = None if is_encrypted: encrypted_content = content_bytes else: - content = content_bytes.decode('utf-8', errors='ignore') + content = content_bytes.decode("utf-8", errors="ignore") # 6. Sender Peer ID if has_sender_peer_id: - peer_id_len = data[offset]; offset += 1 - offset += peer_id_len # Skip peer id + peer_id_len = data[offset] + offset += 1 + offset += peer_id_len # Skip peer id # 7. Channel channel = None if has_channel: - channel_len = data[offset]; offset += 1 - channel = data[offset:offset+channel_len].decode('utf-8') + channel_len = data[offset] + offset += 1 + channel = data[offset : offset + channel_len].decode("utf-8") return BitchatMessage(id_str, content, channel, is_encrypted, encrypted_content) -def create_bitchat_packet(sender_id: str, msg_type: MessageType, payload: bytes) -> bytes: + +def create_bitchat_packet( + sender_id: str, msg_type: MessageType, payload: bytes +) -> bytes: """Create a BitChat packet""" - return create_bitchat_packet_with_recipient(sender_id, None, msg_type, payload, None) + return create_bitchat_packet_with_recipient( + sender_id, None, msg_type, payload, None + ) -def create_bitchat_packet_with_signature(sender_id: str, msg_type: MessageType, - payload: bytes, signature: Optional[bytes]) -> bytes: + +def create_bitchat_packet_with_signature( + sender_id: str, msg_type: MessageType, payload: bytes, signature: Optional[bytes] +) -> bytes: """Create a BitChat packet with signature""" - return create_bitchat_packet_with_recipient(sender_id, None, msg_type, payload, signature) + return create_bitchat_packet_with_recipient( + sender_id, None, msg_type, payload, signature + ) -def create_bitchat_packet_with_recipient_and_signature(sender_id: str, recipient_id: str, - msg_type: MessageType, payload: bytes, - signature: Optional[bytes]) -> bytes: + +def create_bitchat_packet_with_recipient_and_signature( + sender_id: str, + recipient_id: str, + msg_type: MessageType, + payload: bytes, + signature: Optional[bytes], +) -> bytes: """Create a BitChat packet with recipient and signature""" - return create_bitchat_packet_with_recipient(sender_id, recipient_id, msg_type, payload, signature) + return create_bitchat_packet_with_recipient( + sender_id, recipient_id, msg_type, payload, signature + ) -def create_bitchat_packet_with_recipient(sender_id: str, recipient_id: Optional[str], - msg_type: MessageType, payload: bytes, - signature: Optional[bytes]) -> bytes: + +def create_bitchat_packet_with_recipient( + sender_id: str, + recipient_id: Optional[str], + msg_type: MessageType, + payload: bytes, + signature: Optional[bytes], +) -> bytes: """Create a BitChat packet with all options""" - debug_full_println(f"[RAW SEND] Creating packet: type={msg_type.name}, payload_len={len(payload)}") - + debug_full_println( + f"[RAW SEND] Creating packet: type={msg_type.name}, payload_len={len(payload)}" + ) + # Create the packet first packet = bytearray() - + # Version packet.append(1) - + # Type packet.append(msg_type.value) - + # TTL packet.append(7) - + # Timestamp timestamp_ms = int(time.time() * 1000) - packet.extend(struct.pack('>Q', timestamp_ms)) - + packet.extend(struct.pack(">Q", timestamp_ms)) + # Flags flags = 0 # Include recipient field if: - # 1. A specific recipient is provided (targeted message), OR + # 1. A specific recipient is provided (targeted message), OR # 2. This is a message type that uses broadcast recipient (not fragments) - exclude_recipient_types = [MessageType.FRAGMENT_START, MessageType.FRAGMENT_CONTINUE, MessageType.FRAGMENT_END] + exclude_recipient_types = [ + MessageType.FRAGMENT_START, + MessageType.FRAGMENT_CONTINUE, + MessageType.FRAGMENT_END, + ] if recipient_id is not None or msg_type not in exclude_recipient_types: flags |= FLAG_HAS_RECIPIENT if signature: flags |= FLAG_HAS_SIGNATURE packet.append(flags) - + # Payload length - packet.extend(struct.pack('>H', len(payload))) - + packet.extend(struct.pack(">H", len(payload))) + # Sender ID (exactly 8 bytes, padded with zeros if needed) sender_bytes = bytes.fromhex(sender_id) packet.extend(sender_bytes[:8]) # Take first 8 bytes if len(sender_bytes) < 8: packet.extend(bytes(8 - len(sender_bytes))) # Pad with zeros - + # Recipient ID (exactly 8 bytes if present) if flags & FLAG_HAS_RECIPIENT: if recipient_id: @@ -2708,33 +3286,33 @@ def create_bitchat_packet_with_recipient(sender_id: str, recipient_id: Optional[ packet.extend(bytes(8 - len(recipient_bytes))) # Pad with zeros else: packet.extend(BROADCAST_RECIPIENT) - + # Payload packet.extend(payload) - + # Signature if signature: packet.extend(signature) - + # Apply iOS-style padding to standard block sizes for traffic analysis resistance # iOS pads ALL packets to 256 bytes for consistent BLE transmission block_sizes = [256, 512, 1024, 2048] # Account for encryption overhead (~16 bytes for AES-GCM tag) total_size = len(packet) + 16 - + # Find smallest block that fits target_size = None for block_size in block_sizes: if total_size <= block_size: target_size = block_size break - + if target_size is None: # For very large messages, just use the original size (will be fragmented anyway) target_size = len(packet) - + padding_needed = target_size - len(packet) - + # PKCS#7 only supports padding up to 255 bytes # If we need more padding than that, don't pad - return original data if 0 < padding_needed <= 255: @@ -2745,85 +3323,117 @@ def create_bitchat_packet_with_recipient(sender_id: str, recipient_id: Optional[ # Add hex logging to match iOS format final_packet = bytes(packet) - hex_string = ' '.join(f'{b:02X}' for b in final_packet) + hex_string = " ".join(f"{b:02X}" for b in final_packet) debug_full_println(f"[RAW SEND] {hex_string}") - + return final_packet -def create_bitchat_message_payload_full(sender: str, content: str, channel: Optional[str], - is_private: bool, sender_peer_id: str, is_encrypted: bool, encrypted_content: Optional[bytes]) -> Tuple[bytes, str]: + +def create_bitchat_message_payload_full( + sender: str, + content: str, + channel: Optional[str], + is_private: bool, + sender_peer_id: str, + is_encrypted: bool, + encrypted_content: Optional[bytes], +) -> Tuple[bytes, str]: """Create message payload with all fields, matching Swift implementation""" data = bytearray() message_id = str(uuid.uuid4()) # 1. Flags flags = 0 - if is_private: flags |= MSG_FLAG_IS_PRIVATE - if sender_peer_id: flags |= MSG_FLAG_HAS_SENDER_PEER_ID - if channel: flags |= MSG_FLAG_HAS_CHANNEL - if is_encrypted: flags |= MSG_FLAG_IS_ENCRYPTED + if is_private: + flags |= MSG_FLAG_IS_PRIVATE + if sender_peer_id: + flags |= MSG_FLAG_HAS_SENDER_PEER_ID + if channel: + flags |= MSG_FLAG_HAS_CHANNEL + if is_encrypted: + flags |= MSG_FLAG_IS_ENCRYPTED data.append(flags) # 2. Timestamp timestamp_ms = int(time.time() * 1000) - data.extend(struct.pack('>Q', timestamp_ms)) + data.extend(struct.pack(">Q", timestamp_ms)) # 3. ID - id_bytes = message_id.encode('utf-8') + id_bytes = message_id.encode("utf-8") data.append(len(id_bytes)) data.extend(id_bytes) # 4. Sender - sender_bytes = sender.encode('utf-8') + sender_bytes = sender.encode("utf-8") data.append(len(sender_bytes)) data.extend(sender_bytes) # 5. Content - payload_bytes = encrypted_content if is_encrypted and encrypted_content else content.encode('utf-8') - data.extend(struct.pack('>H', len(payload_bytes))) + payload_bytes = ( + encrypted_content + if is_encrypted and encrypted_content + else content.encode("utf-8") + ) + data.extend(struct.pack(">H", len(payload_bytes))) data.extend(payload_bytes) # 6. Sender Peer ID if sender_peer_id: - peer_id_bytes = sender_peer_id.encode('utf-8') + peer_id_bytes = sender_peer_id.encode("utf-8") data.append(len(peer_id_bytes)) data.extend(peer_id_bytes) # 7. Channel if channel: - channel_bytes = channel.encode('utf-8') + channel_bytes = channel.encode("utf-8") data.append(len(channel_bytes)) data.extend(channel_bytes) return (bytes(data), message_id) - - return (bytes(data), message_id) + def unpad_message(data: bytes) -> bytes: """Remove PKCS#7 padding""" if not data: return data - + padding_length = data[-1] - + if padding_length == 0 or padding_length > len(data) or padding_length > 255: return data - + return data[:-padding_length] -def create_encrypted_channel_message_payload(sender: str, content: str, channel: str, key: bytes, encryption_service, sender_peer_id: str) -> Tuple[bytes, str]: + +def create_encrypted_channel_message_payload( + sender: str, + content: str, + channel: str, + key: bytes, + encryption_service, + sender_peer_id: str, +) -> Tuple[bytes, str]: """Create encrypted channel message payload""" encrypted_content = encryption_service.encrypt_with_key(content.encode(), key) - return create_bitchat_message_payload_full(sender, content, channel, False, sender_peer_id, True, encrypted_content) + return create_bitchat_message_payload_full( + sender, content, channel, False, sender_peer_id, True, encrypted_content + ) + def should_fragment(packet: bytes) -> bool: """Check if packet needs fragmentation""" return len(packet) > 500 -def should_send_ack(is_private: bool, channel: Optional[str], mentions: Optional[List[str]], - my_nickname: str, active_peer_count: int) -> bool: + +def should_send_ack( + is_private: bool, + channel: Optional[str], + mentions: Optional[List[str]], + my_nickname: str, + active_peer_count: int, +) -> bool: """Determine if we should send an ACK""" if is_private: return True @@ -2834,13 +3444,15 @@ def should_send_ack(is_private: bool, channel: Optional[str], mentions: Optional return True return False + async def main(): """Main entry point""" client = BitchatClient() await client.run() + if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - print("\n[+] Exiting...") \ No newline at end of file + print("\n[+] Exiting...") diff --git a/compression.py b/compression.py index b3dcf5c..5db119b 100644 --- a/compression.py +++ b/compression.py @@ -4,17 +4,19 @@ COMPRESSION_THRESHOLD = 100 + def compress_if_beneficial(data: bytes) -> Tuple[bytes, bool]: """Compress data if it reduces size""" if len(data) < COMPRESSION_THRESHOLD: return (data, False) - + compressed = lz4.frame.compress(data) if len(compressed) < len(data): return (compressed, True) else: return (data, False) + def decompress(data: bytes) -> bytes: """Decompress LZ4 data""" try: @@ -22,5 +24,6 @@ def decompress(data: bytes) -> bytes: except Exception as e: raise ValueError(f"Decompression failed: {e}") + # Export functions -__all__ = ['compress_if_beneficial', 'decompress', 'COMPRESSION_THRESHOLD'] \ No newline at end of file +__all__ = ["compress_if_beneficial", "decompress", "COMPRESSION_THRESHOLD"] diff --git a/encryption.py b/encryption.py index 451317a..4839de5 100644 --- a/encryption.py +++ b/encryption.py @@ -11,7 +11,10 @@ from dataclasses import dataclass from typing import Optional, Dict, Tuple, Callable from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey +from cryptography.hazmat.primitives.asymmetric.x25519 import ( + X25519PrivateKey, + X25519PublicKey, +) from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.hmac import HMAC @@ -22,124 +25,134 @@ NOISE_DH_LEN = 32 # Curve25519 key size NOISE_HASH_LEN = 32 # SHA256 hash size + class NoiseError(Exception): """Base class for Noise protocol errors""" + pass + class NoiseRole: """Noise handshake roles""" + INITIATOR = "initiator" RESPONDER = "responder" + class NoiseHandshakeState: """ Noise handshake state machine for XX pattern. Implements the Noise Protocol Framework specification. """ - - def __init__(self, role: str, local_static_key: X25519PrivateKey, remote_static_key: Optional[X25519PublicKey] = None): + + def __init__( + self, + role: str, + local_static_key: X25519PrivateKey, + remote_static_key: Optional[X25519PublicKey] = None, + ): self.role = role self.local_static_private = local_static_key self.local_static_public = local_static_key.public_key() self.remote_static_public = remote_static_key - + # Ephemeral keys self.local_ephemeral_private = None self.local_ephemeral_public = None self.remote_ephemeral_public = None - + # Symmetric state self.chaining_key = None self.hash_state = None self.cipher_state = NoiseCipherState() - + # Pattern tracking self.current_pattern = 0 self.message_patterns = self._get_xx_patterns() - + # Initialize symmetric state self._initialize_symmetric_state() - + def _get_xx_patterns(self) -> list: """Get XX pattern message sequences""" return [ - ['e'], # Message 1: -> e - ['e', 'ee', 's', 'es'], # Message 2: <- e, ee, s, es - ['s', 'se'] # Message 3: -> s, se + ["e"], # Message 1: -> e + ["e", "ee", "s", "es"], # Message 2: <- e, ee, s, es + ["s", "se"], # Message 3: -> s, se ] - + def _initialize_symmetric_state(self): """Initialize symmetric state with protocol name""" - protocol_name = NOISE_PROTOCOL_NAME.encode('utf-8') + protocol_name = NOISE_PROTOCOL_NAME.encode("utf-8") if len(protocol_name) <= 32: - self.hash_state = protocol_name + b'\x00' * (32 - len(protocol_name)) + self.hash_state = protocol_name + b"\x00" * (32 - len(protocol_name)) else: self.hash_state = hashlib.sha256(protocol_name).digest() self.chaining_key = self.hash_state - + def _mix_key(self, input_key_material: bytes): """Mix key material into chaining key and update cipher""" - #print(f"[NOISE] _mix_key: input={input_key_material.hex()[:32]}...") - #print(f"[NOISE] _mix_key: chaining_key={self.chaining_key.hex()[:32]}...") - + # print(f"[NOISE] _mix_key: input={input_key_material.hex()[:32]}...") + # print(f"[NOISE] _mix_key: chaining_key={self.chaining_key.hex()[:32]}...") + # HKDF extract step: tempKey = HMAC(chainingKey, inputKeyMaterial) hmac = HMAC(self.chaining_key, hashes.SHA256()) hmac.update(input_key_material) temp_key = hmac.finalize() - #print(f"[NOISE] _mix_key: temp_key={temp_key.hex()[:32]}...") - + # print(f"[NOISE] _mix_key: temp_key={temp_key.hex()[:32]}...") + # HKDF expand step: generate 2 outputs (matching Swift) # output1 = HMAC(tempKey, "" + 0x01) # output2 = HMAC(tempKey, output1 + 0x02) hmac1 = HMAC(temp_key, hashes.SHA256()) - hmac1.update(b'\x01') + hmac1.update(b"\x01") output1 = hmac1.finalize() - + hmac2 = HMAC(temp_key, hashes.SHA256()) - hmac2.update(output1 + b'\x02') + hmac2.update(output1 + b"\x02") output2 = hmac2.finalize() - - #print(f"[NOISE] _mix_key: new_chaining_key={output1.hex()[:32]}...") - #print(f"[NOISE] _mix_key: cipher_key={output2.hex()[:32]}...") - + + # print(f"[NOISE] _mix_key: new_chaining_key={output1.hex()[:32]}...") + # print(f"[NOISE] _mix_key: cipher_key={output2.hex()[:32]}...") + self.chaining_key = output1 self.cipher_state.initialize_key(output2) - + def _mix_hash(self, data: bytes): """Mix data into handshake hash""" digest = hashes.Hash(hashes.SHA256()) digest.update(self.hash_state) digest.update(data) self.hash_state = digest.finalize() - + def _mix_key_and_hash(self, input_key_material: bytes): """Mix key material into both chaining key and hash""" # HKDF extract step: tempKey = HMAC(chainingKey, inputKeyMaterial) hmac = HMAC(self.chaining_key, hashes.SHA256()) hmac.update(input_key_material) temp_key = hmac.finalize() - + # HKDF expand step: generate 3 outputs (matching Swift) # output1 = HMAC(tempKey, "" + 0x01) # output2 = HMAC(tempKey, output1 + 0x02) # output3 = HMAC(tempKey, output2 + 0x03) hmac1 = HMAC(temp_key, hashes.SHA256()) - hmac1.update(b'\x01') + hmac1.update(b"\x01") output1 = hmac1.finalize() - + hmac2 = HMAC(temp_key, hashes.SHA256()) - hmac2.update(output1 + b'\x02') + hmac2.update(output1 + b"\x02") output2 = hmac2.finalize() - + hmac3 = HMAC(temp_key, hashes.SHA256()) - hmac3.update(output2 + b'\x03') + hmac3.update(output2 + b"\x03") output3 = hmac3.finalize() - + self.chaining_key = output1 # Mix output2 into hash_state (matching Swift mixHash behavior) self._mix_hash(output2) self.cipher_state.initialize_key(output3) - + def _encrypt_and_hash(self, plaintext: bytes) -> bytes: """Encrypt plaintext and mix ciphertext into hash""" if self.cipher_state.has_key(): @@ -149,431 +162,495 @@ def _encrypt_and_hash(self, plaintext: bytes) -> bytes: else: self._mix_hash(plaintext) return plaintext - + def _decrypt_and_hash(self, ciphertext: bytes) -> bytes: """Decrypt ciphertext and mix it into hash""" - #print(f"[NOISE] _decrypt_and_hash: ciphertext_len={len(ciphertext)}") - #print(f"[NOISE] _decrypt_and_hash: has_cipher_key={self.cipher_state.has_key()}") - #print(f"[NOISE] _decrypt_and_hash: hash_state={self.hash_state.hex()[:32]}...") - + # print(f"[NOISE] _decrypt_and_hash: ciphertext_len={len(ciphertext)}") + # print(f"[NOISE] _decrypt_and_hash: has_cipher_key={self.cipher_state.has_key()}") + # print(f"[NOISE] _decrypt_and_hash: hash_state={self.hash_state.hex()[:32]}...") + if self.cipher_state.has_key(): plaintext = self.cipher_state.decrypt(ciphertext, self.hash_state) self._mix_hash(ciphertext) - #print(f"[NOISE] _decrypt_and_hash: decrypted {len(ciphertext)} -> {len(plaintext)} bytes") + # print(f"[NOISE] _decrypt_and_hash: decrypted {len(ciphertext)} -> {len(plaintext)} bytes") return plaintext else: self._mix_hash(ciphertext) - #print(f"[NOISE] _decrypt_and_hash: no cipher key, returning plaintext") + # print(f"[NOISE] _decrypt_and_hash: no cipher key, returning plaintext") return ciphertext - + def _dh(self, private_key: X25519PrivateKey, public_key: X25519PublicKey) -> bytes: """Perform Diffie-Hellman key exchange""" shared_key = private_key.exchange(public_key) return shared_key - - def write_message(self, payload: bytes = b'') -> bytes: + + def write_message(self, payload: bytes = b"") -> bytes: """Write a handshake message""" if self.current_pattern >= len(self.message_patterns): raise NoiseError("Handshake complete") - + message_buffer = bytearray() patterns = self.message_patterns[self.current_pattern] - + for pattern in patterns: - if pattern == 'e': + if pattern == "e": # Generate and send ephemeral key self.local_ephemeral_private = X25519PrivateKey.generate() self.local_ephemeral_public = self.local_ephemeral_private.public_key() ephemeral_bytes = self.local_ephemeral_public.public_bytes( encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw + format=serialization.PublicFormat.Raw, ) message_buffer.extend(ephemeral_bytes) self._mix_hash(ephemeral_bytes) - - elif pattern == 's': + + elif pattern == "s": # Send static key (encrypted if cipher is initialized) static_bytes = self.local_static_public.public_bytes( encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw + format=serialization.PublicFormat.Raw, ) encrypted = self._encrypt_and_hash(static_bytes) message_buffer.extend(encrypted) - - elif pattern == 'ee': + + elif pattern == "ee": # DH(local ephemeral, remote ephemeral) if not self.local_ephemeral_private or not self.remote_ephemeral_public: raise NoiseError("Missing ephemeral keys for ee") - shared = self._dh(self.local_ephemeral_private, self.remote_ephemeral_public) + shared = self._dh( + self.local_ephemeral_private, self.remote_ephemeral_public + ) self._mix_key(shared) - - elif pattern == 'es': + + elif pattern == "es": # DH(ephemeral, static) - direction depends on role if self.role == NoiseRole.INITIATOR: - if not self.local_ephemeral_private or not self.remote_static_public: + if ( + not self.local_ephemeral_private + or not self.remote_static_public + ): raise NoiseError("Missing keys for es") - shared = self._dh(self.local_ephemeral_private, self.remote_static_public) + shared = self._dh( + self.local_ephemeral_private, self.remote_static_public + ) else: - if not self.local_static_private or not self.remote_ephemeral_public: + if ( + not self.local_static_private + or not self.remote_ephemeral_public + ): raise NoiseError("Missing keys for es") - shared = self._dh(self.local_static_private, self.remote_ephemeral_public) + shared = self._dh( + self.local_static_private, self.remote_ephemeral_public + ) self._mix_key(shared) - - elif pattern == 'se': + + elif pattern == "se": # DH(static, ephemeral) - direction depends on role if self.role == NoiseRole.INITIATOR: - if not self.local_static_private or not self.remote_ephemeral_public: + if ( + not self.local_static_private + or not self.remote_ephemeral_public + ): raise NoiseError("Missing keys for se") - shared = self._dh(self.local_static_private, self.remote_ephemeral_public) + shared = self._dh( + self.local_static_private, self.remote_ephemeral_public + ) else: - if not self.local_ephemeral_private or not self.remote_static_public: + if ( + not self.local_ephemeral_private + or not self.remote_static_public + ): raise NoiseError("Missing keys for se") - shared = self._dh(self.local_ephemeral_private, self.remote_static_public) + shared = self._dh( + self.local_ephemeral_private, self.remote_static_public + ) self._mix_key(shared) - + # Encrypt payload encrypted_payload = self._encrypt_and_hash(payload) message_buffer.extend(encrypted_payload) - + self.current_pattern += 1 return bytes(message_buffer) - + def read_message(self, message: bytes) -> bytes: """Read a handshake message""" if self.current_pattern >= len(self.message_patterns): raise NoiseError("Handshake complete") - + buffer = message patterns = self.message_patterns[self.current_pattern] - + for pattern in patterns: - if pattern == 'e': + if pattern == "e": # Read ephemeral key if len(buffer) < 32: - raise NoiseError("Invalid message: insufficient data for ephemeral key") + raise NoiseError( + "Invalid message: insufficient data for ephemeral key" + ) ephemeral_data = buffer[:32] buffer = buffer[32:] - - self.remote_ephemeral_public = X25519PublicKey.from_public_bytes(ephemeral_data) + + self.remote_ephemeral_public = X25519PublicKey.from_public_bytes( + ephemeral_data + ) self._mix_hash(ephemeral_data) - - elif pattern == 's': + + elif pattern == "s": # Read static key (may be encrypted) - key_length = 48 if self.cipher_state.has_key() else 32 # 32 + 16 tag if encrypted + key_length = ( + 48 if self.cipher_state.has_key() else 32 + ) # 32 + 16 tag if encrypted if len(buffer) < key_length: - raise NoiseError("Invalid message: insufficient data for static key") + raise NoiseError( + "Invalid message: insufficient data for static key" + ) static_data = buffer[:key_length] buffer = buffer[key_length:] - + decrypted = self._decrypt_and_hash(static_data) self.remote_static_public = X25519PublicKey.from_public_bytes(decrypted) - - elif pattern in ['ee', 'es', 'se']: + + elif pattern in ["ee", "es", "se"]: # Perform DH operations (same as write_message) - if pattern == 'ee': - if not self.local_ephemeral_private or not self.remote_ephemeral_public: + if pattern == "ee": + if ( + not self.local_ephemeral_private + or not self.remote_ephemeral_public + ): raise NoiseError("Missing ephemeral keys for ee") - shared = self._dh(self.local_ephemeral_private, self.remote_ephemeral_public) + shared = self._dh( + self.local_ephemeral_private, self.remote_ephemeral_public + ) self._mix_key(shared) - elif pattern == 'es': + elif pattern == "es": if self.role == NoiseRole.INITIATOR: - if not self.local_ephemeral_private or not self.remote_static_public: + if ( + not self.local_ephemeral_private + or not self.remote_static_public + ): raise NoiseError("Missing keys for es") - shared = self._dh(self.local_ephemeral_private, self.remote_static_public) + shared = self._dh( + self.local_ephemeral_private, self.remote_static_public + ) else: - if not self.local_static_private or not self.remote_ephemeral_public: + if ( + not self.local_static_private + or not self.remote_ephemeral_public + ): raise NoiseError("Missing keys for es") - shared = self._dh(self.local_static_private, self.remote_ephemeral_public) + shared = self._dh( + self.local_static_private, self.remote_ephemeral_public + ) self._mix_key(shared) - elif pattern == 'se': + elif pattern == "se": if self.role == NoiseRole.INITIATOR: - if not self.local_static_private or not self.remote_ephemeral_public: + if ( + not self.local_static_private + or not self.remote_ephemeral_public + ): raise NoiseError("Missing keys for se") - shared = self._dh(self.local_static_private, self.remote_ephemeral_public) + shared = self._dh( + self.local_static_private, self.remote_ephemeral_public + ) else: - if not self.local_ephemeral_private or not self.remote_static_public: + if ( + not self.local_ephemeral_private + or not self.remote_static_public + ): raise NoiseError("Missing keys for se") - shared = self._dh(self.local_ephemeral_private, self.remote_static_public) + shared = self._dh( + self.local_ephemeral_private, self.remote_static_public + ) self._mix_key(shared) - + # Decrypt payload payload = self._decrypt_and_hash(buffer) self.current_pattern += 1 - + return payload - + def is_handshake_complete(self) -> bool: """Check if handshake is complete""" return self.current_pattern >= len(self.message_patterns) - - def get_transport_ciphers(self) -> Tuple['NoiseCipherState', 'NoiseCipherState']: + + def get_transport_ciphers(self) -> Tuple["NoiseCipherState", "NoiseCipherState"]: """Get transport cipher states after handshake completion""" if not self.is_handshake_complete(): raise NoiseError("Handshake not complete") - + # Split function: derive two cipher states (matching Swift) # tempKey = HMAC(chainingKey, "") hmac = HMAC(self.chaining_key, hashes.SHA256()) - hmac.update(b'') + hmac.update(b"") temp_key = hmac.finalize() - + # Generate 2 outputs hmac1 = HMAC(temp_key, hashes.SHA256()) - hmac1.update(b'\x01') + hmac1.update(b"\x01") key1 = hmac1.finalize() - + hmac2 = HMAC(temp_key, hashes.SHA256()) - hmac2.update(key1 + b'\x02') + hmac2.update(key1 + b"\x02") key2 = hmac2.finalize() - + c1 = NoiseCipherState() c1.initialize_key(key1) - + c2 = NoiseCipherState() c2.initialize_key(key2) - + # Initiator uses c1 for sending, c2 for receiving # Responder uses c2 for sending, c1 for receiving if self.role == NoiseRole.INITIATOR: return c1, c2 else: return c2, c1 - + def get_handshake_hash(self) -> bytes: """Get the handshake hash for channel binding""" return self.hash_state - + def get_remote_static_public_key(self) -> Optional[X25519PublicKey]: """Get the remote static public key""" return self.remote_static_public + class NoiseCipherState: """Cipher state for Noise Protocol transport encryption""" - + def __init__(self): self.key = None self.nonce = 0 - + def initialize_key(self, key: bytes): """Initialize cipher with key""" self.key = key self.nonce = 0 - + def has_key(self) -> bool: """Check if cipher has a key""" return self.key is not None - - def encrypt(self, plaintext: bytes, associated_data: bytes = b'') -> bytes: + + def encrypt(self, plaintext: bytes, associated_data: bytes = b"") -> bytes: """Encrypt plaintext with ChaCha20-Poly1305""" if not self.has_key(): raise NoiseError("Cipher not initialized") - + # Create nonce from counter (12 bytes, matching Swift) # Swift puts counter at positions 4-12, zeros at 0-4 - nonce = b'\x00\x00\x00\x00' + self.nonce.to_bytes(8, byteorder='little') - + nonce = b"\x00\x00\x00\x00" + self.nonce.to_bytes(8, byteorder="little") + cipher = ChaCha20Poly1305(self.key) ciphertext = cipher.encrypt(nonce, plaintext, associated_data) - + self.nonce += 1 return ciphertext - - def decrypt(self, ciphertext: bytes, associated_data: bytes = b'') -> bytes: + + def decrypt(self, ciphertext: bytes, associated_data: bytes = b"") -> bytes: """Decrypt ciphertext with ChaCha20-Poly1305""" if not self.has_key(): raise NoiseError("Cipher not initialized") - - #print(f"[NOISE] NoiseCipher.decrypt: nonce={self.nonce}, ciphertext_len={len(ciphertext)}, ad_len={len(associated_data)}") - #print(f"[NOISE] NoiseCipher.decrypt: ad_hex={associated_data.hex()[:32]}...") - + + # print(f"[NOISE] NoiseCipher.decrypt: nonce={self.nonce}, ciphertext_len={len(ciphertext)}, ad_len={len(associated_data)}") + # print(f"[NOISE] NoiseCipher.decrypt: ad_hex={associated_data.hex()[:32]}...") + # Create nonce from counter (12 bytes, matching Swift) # Swift puts counter at positions 4-12, zeros at 0-4 - nonce = b'\x00\x00\x00\x00' + self.nonce.to_bytes(8, byteorder='little') - #print(f"[NOISE] NoiseCipher.decrypt: nonce_bytes={nonce.hex()}") - + nonce = b"\x00\x00\x00\x00" + self.nonce.to_bytes(8, byteorder="little") + # print(f"[NOISE] NoiseCipher.decrypt: nonce_bytes={nonce.hex()}") + cipher = ChaCha20Poly1305(self.key) try: plaintext = cipher.decrypt(nonce, ciphertext, associated_data) self.nonce += 1 - #print(f"[NOISE] NoiseCipher.decrypt: SUCCESS, plaintext_len={len(plaintext)}") + # print(f"[NOISE] NoiseCipher.decrypt: SUCCESS, plaintext_len={len(plaintext)}") return plaintext except Exception as e: - #print(f"[NOISE] NoiseCipher.decrypt: FAILED with {type(e).__name__}: {e}") - #print(f"[NOISE] NoiseCipher.decrypt: key={self.key.hex()[:32]}...") + # print(f"[NOISE] NoiseCipher.decrypt: FAILED with {type(e).__name__}: {e}") + # print(f"[NOISE] NoiseCipher.decrypt: key={self.key.hex()[:32]}...") # Increment nonce even on failure to maintain sync (Noise protocol requirement) self.nonce += 1 raise + @dataclass class NoiseSession: """Represents an established Noise session with a peer""" + peer_id: str send_cipher: NoiseCipherState receive_cipher: NoiseCipherState remote_static_key: X25519PublicKey established_time: float - + def encrypt(self, plaintext: bytes) -> bytes: """Encrypt data for transport""" return self.send_cipher.encrypt(plaintext) - + def decrypt(self, ciphertext: bytes) -> bytes: """Decrypt received data""" return self.receive_cipher.decrypt(ciphertext) - + def get_fingerprint(self) -> str: """Get peer's public key fingerprint""" key_bytes = self.remote_static_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw ) return hashlib.sha256(key_bytes).hexdigest() + class EncryptionService: """ Main encryption service implementing both Noise Protocol and legacy encryption. Compatible with Swift NoiseEncryptionService. """ - + def __init__(self, identity_path: Optional[str] = None): # Load or create static identity key self.static_identity_key = self._load_or_create_identity(identity_path) - + # Active Noise sessions self.sessions: Dict[str, NoiseSession] = {} - + # Handshake states in progress self.handshake_states: Dict[str, NoiseHandshakeState] = {} - + # Store our peer ID for tie-breaking (set from outside) self.my_peer_id: Optional[str] = None - + # Callbacks self.on_peer_authenticated: Optional[Callable[[str, str], None]] = None self.on_handshake_required: Optional[Callable[[str], None]] = None - - def _load_or_create_identity(self, identity_path: Optional[str]) -> X25519PrivateKey: + + def _load_or_create_identity( + self, identity_path: Optional[str] + ) -> X25519PrivateKey: """Load existing identity or create new one""" if identity_path and os.path.exists(identity_path): try: - with open(identity_path, 'rb') as f: + with open(identity_path, "rb") as f: key_data = f.read() return X25519PrivateKey.from_private_bytes(key_data) except Exception: pass # Fall through to create new key - + # Create new identity key = X25519PrivateKey.generate() - + # Save if path provided if identity_path: try: os.makedirs(os.path.dirname(identity_path), exist_ok=True) - with open(identity_path, 'wb') as f: - f.write(key.private_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PrivateFormat.Raw, - encryption_algorithm=serialization.NoEncryption() - )) + with open(identity_path, "wb") as f: + f.write( + key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + ) # Set restrictive permissions os.chmod(identity_path, 0o600) except Exception: pass # Identity will be ephemeral - + return key - + def get_identity_fingerprint(self) -> str: """Get our identity fingerprint""" public_key = self.static_identity_key.public_key() key_bytes = public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw ) return hashlib.sha256(key_bytes).hexdigest() - + def get_public_key_bytes(self) -> bytes: """Get our public key bytes for sharing""" public_key = self.static_identity_key.public_key() return public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw ) - + def get_public_key(self) -> bytes: """Get public key bytes (alias for compatibility)""" return self.get_public_key_bytes() - + def get_combined_public_key_data(self) -> bytes: """Get combined public key data for legacy compatibility""" return self.get_public_key_bytes() - + def get_signing_public_key_bytes(self) -> bytes: """Get signing public key bytes (for now, same as static key)""" # For compatibility with Swift, we'll use the same key for signing # In a full implementation, this would be a separate Ed25519 key return self.get_public_key_bytes() - + def initiate_handshake(self, peer_id: str) -> bytes: """Initiate Noise handshake with a peer""" # Clean up any existing handshake state and session if peer_id in self.handshake_states: - #print(f"[NOISE] Cleaning up existing handshake state for {peer_id}") + # print(f"[NOISE] Cleaning up existing handshake state for {peer_id}") del self.handshake_states[peer_id] - + if peer_id in self.sessions: - #print(f"[NOISE] Removing existing session for {peer_id}") + # print(f"[NOISE] Removing existing session for {peer_id}") del self.sessions[peer_id] - + # Create new handshake state as initiator handshake = NoiseHandshakeState(NoiseRole.INITIATOR, self.static_identity_key) self.handshake_states[peer_id] = handshake - #print(f"[NOISE] Initiating handshake with {peer_id}") - + # print(f"[NOISE] Initiating handshake with {peer_id}") + # Write first message (-> e) return handshake.write_message() - - def process_handshake_message(self, peer_id: str, message: bytes) -> Optional[bytes]: + + def process_handshake_message( + self, peer_id: str, message: bytes + ) -> Optional[bytes]: """Process incoming handshake message and return response if needed""" - + # Validate input if not message: raise NoiseError("Empty handshake message") - + if len(message) < 32: raise NoiseError(f"Handshake message too short: {len(message)} bytes") - + # Check if we have an ongoing handshake if peer_id in self.handshake_states: handshake = self.handshake_states[peer_id] - #print(f"[NOISE] Continuing handshake with {peer_id}, pattern {handshake.current_pattern}, role {handshake.role}") + # print(f"[NOISE] Continuing handshake with {peer_id}, pattern {handshake.current_pattern}, role {handshake.role}") else: # New handshake from peer - we are responder - handshake = NoiseHandshakeState(NoiseRole.RESPONDER, self.static_identity_key) + handshake = NoiseHandshakeState( + NoiseRole.RESPONDER, self.static_identity_key + ) self.handshake_states[peer_id] = handshake - #print(f"[NOISE] Starting new handshake with {peer_id} as responder") - + # print(f"[NOISE] Starting new handshake with {peer_id} as responder") + # Validate handshake state if handshake.current_pattern >= len(handshake.message_patterns): - #print(f"[NOISE] Warning: Handshake already complete with {peer_id}, ignoring message") + # print(f"[NOISE] Warning: Handshake already complete with {peer_id}, ignoring message") return None - + try: # Read the incoming message payload = handshake.read_message(message) - #print(f"[NOISE] Successfully processed pattern {handshake.current_pattern - 1} from {peer_id}") - + # print(f"[NOISE] Successfully processed pattern {handshake.current_pattern - 1} from {peer_id}") + # Check if we need to send a response response = None if not handshake.is_handshake_complete(): # Generate response message response = handshake.write_message() - #print(f"[NOISE] Generated response pattern {handshake.current_pattern - 1} for {peer_id}") - + # print(f"[NOISE] Generated response pattern {handshake.current_pattern - 1} for {peer_id}") + # Check if handshake is now complete if handshake.is_handshake_complete(): # Get transport ciphers send_cipher, receive_cipher = handshake.get_transport_ciphers() - + # Create session remote_key = handshake.get_remote_static_public_key() if remote_key: @@ -582,164 +659,170 @@ def process_handshake_message(self, peer_id: str, message: bytes) -> Optional[by send_cipher=send_cipher, receive_cipher=receive_cipher, remote_static_key=remote_key, - established_time=time.time() + established_time=time.time(), ) self.sessions[peer_id] = session - + # Cleanup handshake state del self.handshake_states[peer_id] - #print(f"[NOISE] Handshake completed with {peer_id}") - + # print(f"[NOISE] Handshake completed with {peer_id}") + # Notify authentication if self.on_peer_authenticated: fingerprint = session.get_fingerprint() self.on_peer_authenticated(peer_id, fingerprint) - + return response - + except Exception as e: # Handshake failed, cleanup if peer_id in self.handshake_states: del self.handshake_states[peer_id] - #print(f"[NOISE] Handshake failed with {peer_id}: {type(e).__name__}: {e}") - #print(f"[NOISE] Message length: {len(message)}, first 32 bytes: {message[:32].hex()}") + # print(f"[NOISE] Handshake failed with {peer_id}: {type(e).__name__}: {e}") + # print(f"[NOISE] Message length: {len(message)}, first 32 bytes: {message[:32].hex()}") import traceback - #print(f"[NOISE] Handshake error details: {traceback.format_exc()}") - #print(f"[NOISE] Original exception type: {type(e).__name__}") - #print(f"[NOISE] Original exception message: {str(e)}") + + # print(f"[NOISE] Handshake error details: {traceback.format_exc()}") + # print(f"[NOISE] Original exception type: {type(e).__name__}") + # print(f"[NOISE] Original exception message: {str(e)}") raise NoiseError(f"Handshake failed: {e}") - + def handle_handshake_message(self, peer_id: str, message: bytes) -> Optional[bytes]: """Legacy compatibility method - delegates to process_handshake_message""" return self.process_handshake_message(peer_id, message) - + def has_established_session(self, peer_id: str) -> bool: """Check if we have an established session with peer""" return peer_id in self.sessions - + def is_session_established(self, peer_id: str) -> bool: """Check if we have an established session with peer (alias for compatibility)""" return self.has_established_session(peer_id) - + def encrypt(self, data: bytes, peer_id: str) -> bytes: """Encrypt data for a specific peer""" if peer_id not in self.sessions: if self.on_handshake_required: self.on_handshake_required(peer_id) raise NoiseError(f"No session with peer {peer_id}") - + session = self.sessions[peer_id] return session.encrypt(data) - + def encrypt_for_peer(self, peer_id: str, data: bytes) -> bytes: """Encrypt data for a specific peer (reordered args for compatibility)""" return self.encrypt(data, peer_id) - + def decrypt_from_peer(self, peer_id: str, data: bytes) -> bytes: """Decrypt data from a specific peer""" if peer_id not in self.sessions: raise NoiseError(f"No session with peer {peer_id}") - + session = self.sessions[peer_id] return session.decrypt(data) - + def get_peer_fingerprint(self, peer_id: str) -> Optional[str]: """Get fingerprint for a peer""" if peer_id in self.sessions: return self.sessions[peer_id].get_fingerprint() return None - + def sign_data(self, data: bytes) -> bytes: """Sign data with our identity key (placeholder for EdDSA)""" # For now, return a simple hash-based signature # In a real implementation, this would use EdDSA return hashlib.sha256(data + self.get_public_key_bytes()).digest() - + def remove_session(self, peer_id: str): """Remove session with a peer""" if peer_id in self.sessions: del self.sessions[peer_id] if peer_id in self.handshake_states: del self.handshake_states[peer_id] - + def clear_handshake_state(self, peer_id: str): """Clear handshake state for a peer (used when handshake fails)""" if peer_id in self.handshake_states: - #print(f"[NOISE] Clearing failed handshake state for {peer_id}") + # print(f"[NOISE] Clearing failed handshake state for {peer_id}") del self.handshake_states[peer_id] - + def cleanup_old_sessions(self, max_age: float = 3600): """Remove sessions older than max_age seconds""" current_time = time.time() expired_peers = [] - + for peer_id, session in self.sessions.items(): if current_time - session.established_time > max_age: expired_peers.append(peer_id) - + for peer_id in expired_peers: del self.sessions[peer_id] - + def get_session_count(self) -> int: """Get number of active sessions""" return len(self.sessions) - + def get_active_peers(self) -> list: """Get list of peers with active sessions""" return list(self.sessions.keys()) - + # Channel encryption methods (basic implementation) - def encrypt_for_channel(self, message: str, channel: str, key: bytes, creator_fingerprint: str) -> bytes: + def encrypt_for_channel( + self, message: str, channel: str, key: bytes, creator_fingerprint: str + ) -> bytes: """Encrypt message for channel""" cipher = ChaCha20Poly1305(key) nonce = os.urandom(12) - plaintext = message.encode('utf-8') + plaintext = message.encode("utf-8") return nonce + cipher.encrypt(nonce, plaintext, None) - - def decrypt_from_channel(self, data: bytes, channel: str, key: bytes, creator_fingerprint: str) -> str: + + def decrypt_from_channel( + self, data: bytes, channel: str, key: bytes, creator_fingerprint: str + ) -> str: """Decrypt message from channel""" if len(data) < 12: raise ValueError("Invalid encrypted data") - + nonce = data[:12] ciphertext = data[12:] - + cipher = ChaCha20Poly1305(key) plaintext = cipher.decrypt(nonce, ciphertext, None) - return plaintext.decode('utf-8') - + return plaintext.decode("utf-8") + def encrypt_with_key(self, data: bytes, key: bytes) -> bytes: """Encrypt data with a specific key""" cipher = ChaCha20Poly1305(key) nonce = os.urandom(12) return nonce + cipher.encrypt(nonce, data, None) - + @staticmethod def derive_channel_key(password: str, channel: str) -> bytes: """Derive a channel key from password and channel name""" from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - salt = channel.encode('utf-8') + + salt = channel.encode("utf-8") kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000, ) - return kdf.derive(password.encode('utf-8')) - + return kdf.derive(password.encode("utf-8")) + # def debug_handshake_state(self, peer_id: str = None): # """Debug handshake and session state""" # #print(f"[NOISE DEBUG] ===== Encryption Service State =====") # #print(f"[NOISE DEBUG] Total handshake states: {len(self.handshake_states)}") # #print(f"[NOISE DEBUG] Total sessions: {len(self.sessions)}") - + # if peer_id: # if peer_id in self.handshake_states: # hs = self.handshake_states[peer_id] # #print(f"[NOISE DEBUG] {peer_id} handshake: pattern {hs.current_pattern}, role {hs.role}") # else: # #print(f"[NOISE DEBUG] {peer_id}: no handshake state") - + # if peer_id in self.sessions: # #print(f"[NOISE DEBUG] {peer_id}: has established session") # else: @@ -747,7 +830,7 @@ def derive_channel_key(password: str, channel: str) -> bytes: # else: # for pid, hs in self.handshake_states.items(): # #print(f"[NOISE DEBUG] {pid}: pattern {hs.current_pattern}, role {hs.role}") - + # for pid in self.sessions.keys(): # #print(f"[NOISE DEBUG] {pid}: established session") # #print(f"[NOISE DEBUG] =====================================") diff --git a/fragmentation.py b/fragmentation.py index 025eec6..9533cde 100644 --- a/fragmentation.py +++ b/fragmentation.py @@ -5,11 +5,13 @@ MAX_FRAGMENT_SIZE = 500 + class FragmentType(IntEnum): START = 0x05 CONTINUE = 0x06 END = 0x07 + @dataclass class Fragment: fragment_id: bytes @@ -19,15 +21,19 @@ class Fragment: original_type: int data: bytes + def fragment_payload(payload: bytes, original_msg_type: int) -> List[Fragment]: """Fragment a large payload""" if len(payload) <= MAX_FRAGMENT_SIZE: return [] - + fragment_id = os.urandom(8) - chunks = [payload[i:i+MAX_FRAGMENT_SIZE] for i in range(0, len(payload), MAX_FRAGMENT_SIZE)] + chunks = [ + payload[i : i + MAX_FRAGMENT_SIZE] + for i in range(0, len(payload), MAX_FRAGMENT_SIZE) + ] total = len(chunks) - + fragments = [] for i, chunk in enumerate(chunks): if i == 0: @@ -36,17 +42,20 @@ def fragment_payload(payload: bytes, original_msg_type: int) -> List[Fragment]: fragment_type = FragmentType.END else: fragment_type = FragmentType.CONTINUE - - fragments.append(Fragment( - fragment_id=fragment_id, - fragment_type=fragment_type, - index=i, - total=total, - original_type=original_msg_type, - data=chunk - )) - + + fragments.append( + Fragment( + fragment_id=fragment_id, + fragment_type=fragment_type, + index=i, + total=total, + original_type=original_msg_type, + data=chunk, + ) + ) + return fragments + # Export classes and functions -__all__ = ['Fragment', 'FragmentType', 'fragment_payload', 'MAX_FRAGMENT_SIZE'] \ No newline at end of file +__all__ = ["Fragment", "FragmentType", "fragment_payload", "MAX_FRAGMENT_SIZE"] diff --git a/persistence.py b/persistence.py index 3514f49..faec915 100644 --- a/persistence.py +++ b/persistence.py @@ -10,11 +10,13 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import ed25519 + @dataclass class EncryptedPassword: nonce: List[int] ciphertext: List[int] + @dataclass class AppState: nickname: Optional[str] = None @@ -25,7 +27,10 @@ class AppState: channel_key_commitments: Dict[str, str] = field(default_factory=dict) favorites: Set[str] = field(default_factory=set) identity_key: Optional[List[int]] = None - encrypted_channel_passwords: Dict[str, EncryptedPassword] = field(default_factory=dict) + encrypted_channel_passwords: Dict[str, EncryptedPassword] = field( + default_factory=dict + ) + def get_state_file_path() -> Path: """Get the state file path""" @@ -34,6 +39,7 @@ def get_state_file_path() -> Path: bitchat_dir.mkdir(exist_ok=True) return bitchat_dir / "state.json" + class AppStateEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, set): @@ -42,75 +48,83 @@ def default(self, obj): return {"nonce": obj.nonce, "ciphertext": obj.ciphertext} return super().default(obj) + def load_state() -> AppState: """Load app state from disk""" path = get_state_file_path() - + if path.exists(): try: - with open(path, 'r') as f: + with open(path, "r") as f: data = json.load(f) - + # Convert lists back to sets - if 'blocked_peers' in data: - data['blocked_peers'] = set(data['blocked_peers']) - if 'password_protected_channels' in data: - data['password_protected_channels'] = set(data['password_protected_channels']) - if 'favorites' in data: - data['favorites'] = set(data['favorites']) - + if "blocked_peers" in data: + data["blocked_peers"] = set(data["blocked_peers"]) + if "password_protected_channels" in data: + data["password_protected_channels"] = set( + data["password_protected_channels"] + ) + if "favorites" in data: + data["favorites"] = set(data["favorites"]) + # Convert encrypted passwords - if 'encrypted_channel_passwords' in data: + if "encrypted_channel_passwords" in data: encrypted_passwords = {} - for channel, enc_data in data['encrypted_channel_passwords'].items(): + for channel, enc_data in data[ + "encrypted_channel_passwords" + ].items(): encrypted_passwords[channel] = EncryptedPassword( - nonce=enc_data['nonce'], - ciphertext=enc_data['ciphertext'] + nonce=enc_data["nonce"], ciphertext=enc_data["ciphertext"] ) - data['encrypted_channel_passwords'] = encrypted_passwords - + data["encrypted_channel_passwords"] = encrypted_passwords + state = AppState(**data) except Exception as e: print(f"Warning: Could not parse state file: {e}") state = AppState() else: state = AppState() - + # Generate identity key if not present if state.identity_key is None: signing_key = ed25519.Ed25519PrivateKey.generate() - state.identity_key = list(signing_key.private_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PrivateFormat.Raw, - encryption_algorithm=serialization.NoEncryption() - )) + state.identity_key = list( + signing_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + ) save_state(state) - + return state + def save_state(state: AppState) -> None: """Save app state to disk""" path = get_state_file_path() - + # Convert to dict for JSON serialization data = { - 'nickname': state.nickname, - 'blocked_peers': list(state.blocked_peers), - 'channel_creators': state.channel_creators, - 'joined_channels': state.joined_channels, - 'password_protected_channels': list(state.password_protected_channels), - 'channel_key_commitments': state.channel_key_commitments, - 'favorites': list(state.favorites), - 'identity_key': state.identity_key, - 'encrypted_channel_passwords': { - channel: {'nonce': ep.nonce, 'ciphertext': ep.ciphertext} + "nickname": state.nickname, + "blocked_peers": list(state.blocked_peers), + "channel_creators": state.channel_creators, + "joined_channels": state.joined_channels, + "password_protected_channels": list(state.password_protected_channels), + "channel_key_commitments": state.channel_key_commitments, + "favorites": list(state.favorites), + "identity_key": state.identity_key, + "encrypted_channel_passwords": { + channel: {"nonce": ep.nonce, "ciphertext": ep.ciphertext} for channel, ep in state.encrypted_channel_passwords.items() - } + }, } - - with open(path, 'w') as f: + + with open(path, "w") as f: json.dump(data, f, indent=2) + def derive_encryption_key(identity_key: bytes) -> bytes: """Derive AES key from identity key""" h = hashlib.sha256() @@ -118,32 +132,39 @@ def derive_encryption_key(identity_key: bytes) -> bytes: h.update(identity_key) return h.digest() + def encrypt_password(password: str, identity_key: List[int]) -> EncryptedPassword: """Encrypt a password using the identity key""" identity_key_bytes = bytes(identity_key) key = derive_encryption_key(identity_key_bytes) - + aesgcm = AESGCM(key) nonce = os.urandom(12) ciphertext = aesgcm.encrypt(nonce, password.encode(), None) - - return EncryptedPassword( - nonce=list(nonce), - ciphertext=list(ciphertext) - ) + + return EncryptedPassword(nonce=list(nonce), ciphertext=list(ciphertext)) + def decrypt_password(encrypted: EncryptedPassword, identity_key: List[int]) -> str: """Decrypt a password using the identity key""" identity_key_bytes = bytes(identity_key) key = derive_encryption_key(identity_key_bytes) - + aesgcm = AESGCM(key) nonce = bytes(encrypted.nonce) ciphertext = bytes(encrypted.ciphertext) - + plaintext = aesgcm.decrypt(nonce, ciphertext, None) return plaintext.decode() + # Export classes and functions -__all__ = ['EncryptedPassword', 'AppState', 'get_state_file_path', 'load_state', 'save_state', - 'encrypt_password', 'decrypt_password'] \ No newline at end of file +__all__ = [ + "EncryptedPassword", + "AppState", + "get_state_file_path", + "load_state", + "save_state", + "encrypt_password", + "decrypt_password", +] diff --git a/terminal_ux.py b/terminal_ux.py index 9e1184e..7165309 100644 --- a/terminal_ux.py +++ b/terminal_ux.py @@ -2,34 +2,43 @@ from typing import Dict, List, Optional, Tuple from dataclasses import dataclass + @dataclass class ChatMode: """Base class for chat modes""" + pass + @dataclass class Public(ChatMode): """Public chat mode""" + pass + @dataclass class Channel(ChatMode): """Channel chat mode""" + name: str + @dataclass class PrivateDM(ChatMode): """Private DM mode""" + nickname: str peer_id: str + class ChatContext: def __init__(self): self.current_mode: ChatMode = Public() self.active_channels: List[str] = [] self.active_dms: Dict[str, str] = {} # nickname -> peer_id self.last_private_sender: Optional[Tuple[str, str]] = None - + def format_prompt(self) -> str: if isinstance(self.current_mode, Public): return "[Public]" @@ -38,26 +47,28 @@ def format_prompt(self) -> str: elif isinstance(self.current_mode, PrivateDM): return f"[DM: {self.current_mode.nickname}]" return ">" - + def get_status_line(self) -> str: parts = ["[1] Public"] - + for i, channel in enumerate(self.active_channels): parts.append(f"[{i + 2}] {channel}") - + dm_start = 2 + len(self.active_channels) for i, (nick, _) in enumerate(self.active_dms.items()): parts.append(f"[{i + dm_start}] DM:{nick}") - + return f"Active: {' '.join(parts)}" - + def switch_to_number(self, num: int) -> bool: if num == 1: self.current_mode = Public() print("\033[90m─────────────────────────\033[0m") - print("\033[90m» Switched to Public chat. Just type to send messages.\033[0m") + print( + "\033[90m» Switched to Public chat. Just type to send messages.\033[0m" + ) return True - + channel_end = 1 + len(self.active_channels) if 1 < num <= channel_end: channel_idx = num - 2 @@ -67,7 +78,7 @@ def switch_to_number(self, num: int) -> bool: print("\033[90m─────────────────────────\033[0m") print(f"\033[90m» Switched to channel {channel}\033[0m") return True - + dm_start = channel_end + 1 dm_idx = num - dm_start dm_list = list(self.active_dms.items()) @@ -75,95 +86,106 @@ def switch_to_number(self, num: int) -> bool: nick, peer_id = dm_list[dm_idx] self.current_mode = PrivateDM(nick, peer_id) print("\033[90m─────────────────────────\033[0m") - print(f"\033[90m» Switched to DM with {nick}. Just type to send messages.\033[0m") + print( + f"\033[90m» Switched to DM with {nick}. Just type to send messages.\033[0m" + ) return True - + return False - + def add_channel(self, channel: str): if channel not in self.active_channels: self.active_channels.append(channel) - + def add_dm(self, nickname: str, peer_id: str): self.active_dms[nickname] = peer_id - + def enter_dm_mode(self, nickname: str, peer_id: str): self.add_dm(nickname, peer_id) self.current_mode = PrivateDM(nickname, peer_id) print("\033[90m─────────────────────────\033[0m") - print(f"\033[90m» Entered DM mode with {nickname}. Just type to send messages.\033[0m") - + print( + f"\033[90m» Entered DM mode with {nickname}. Just type to send messages.\033[0m" + ) + def switch_to_channel(self, channel: str): self.add_channel(channel) self.current_mode = Channel(channel) print("\033[90m─────────────────────────\033[0m") print(f"\033[90m» Switched to channel {channel}\033[0m") - + def switch_to_channel_silent(self, channel: str): self.add_channel(channel) self.current_mode = Channel(channel) - + def switch_to_public(self): self.current_mode = Public() print("\033[90m─────────────────────────\033[0m") print("\033[90m» Switched to Public chat. Just type to send messages.\033[0m") - + def remove_channel(self, channel: str): if channel in self.active_channels: self.active_channels.remove(channel) - + def show_conversation_list(self): print("\n╭─── Active Conversations ───╮") print("│ │") - + # Public indicator = "→" if isinstance(self.current_mode, Public) else " " print(f"│ {indicator} [1] Public │") - + # Channels num = 2 for channel in self.active_channels: - is_current = isinstance(self.current_mode, Channel) and self.current_mode.name == channel + is_current = ( + isinstance(self.current_mode, Channel) + and self.current_mode.name == channel + ) indicator = "→" if is_current else " " padding = " " * (18 - len(channel)) print(f"│ {indicator} [{num}] {channel}{padding}│") num += 1 - + # DMs for nick, _ in self.active_dms.items(): - is_current = isinstance(self.current_mode, PrivateDM) and self.current_mode.nickname == nick + is_current = ( + isinstance(self.current_mode, PrivateDM) + and self.current_mode.nickname == nick + ) indicator = "→" if is_current else " " dm_text = f"DM: {nick}" padding = " " * (18 - len(dm_text)) print(f"│ {indicator} [{num}] {dm_text}{padding}│") num += 1 - + print("│ │") print("╰────────────────────────────╯") - + def get_conversation_list_with_numbers(self) -> str: output = "╭─── Select Conversation ───╮\n" - + # Public output += "│ 1. Public │\n" - + # Channels num = 2 for channel in self.active_channels: padding = " " * (20 - len(channel)) output += f"│ {num}. {channel}{padding}│\n" num += 1 - + # DMs for nick, _ in self.active_dms.items(): dm_text = f"DM: {nick}" padding = " " * (20 - len(dm_text)) output += f"│ {num}. {dm_text}{padding}│\n" num += 1 - + output += "╰───────────────────────────╯" return output + def format_message_display( timestamp: datetime, sender: str, @@ -172,11 +194,11 @@ def format_message_display( is_channel: bool, channel_name: Optional[str], recipient: Optional[str], - my_nickname: str + my_nickname: str, ) -> str: """Format a message for display""" time_str = timestamp.strftime("%H:%M") - + if is_private: # Orange for private messages (matching iOS) if sender == my_nickname: @@ -211,10 +233,11 @@ def format_message_display( # Other users - normal green return f"\033[2;32m[{time_str}]\033[0m \033[32m<{sender}>\033[0m {content}" + def print_help(): """Print help menu""" print("\n\033[38;5;46m━━━ BitChat Commands ━━━\033[0m\n") - + # General print("\033[38;5;40m▶ General\033[0m") print(" \033[36m/help\033[0m Show this help menu") @@ -222,46 +245,61 @@ def print_help(): print(" \033[36m/status\033[0m Show connection info") print(" \033[36m/clear\033[0m Clear the screen") print(" \033[36m/exit\033[0m Quit BitChat\n") - + # Navigation print("\033[38;5;40m▶ Navigation\033[0m") print(" \033[36m1-9\033[0m Quick switch to conversation") print(" \033[36m/list\033[0m Show all conversations") print(" \033[36m/switch\033[0m Interactive conversation switcher") print(" \033[36m/public\033[0m Go to public chat\n") - + # Messaging print("\033[38;5;40m▶ Messaging\033[0m") print(" \033[90m(type normally to send in current mode)\033[0m") print(" \033[36m/dm\033[0m \033[90m\033[0m Start private conversation") print(" \033[36m/dm\033[0m \033[90m \033[0m Send quick private message") print(" \033[36m/reply\033[0m Reply to last private message\n") - + # Channels print("\033[38;5;40m▶ Channels\033[0m") print(" \033[36m/j\033[0m \033[90m#channel\033[0m Join or create a channel") print(" \033[36m/j\033[0m \033[90m#channel \033[0m Join with password") print(" \033[36m/leave\033[0m Leave current channel") - print(" \033[36m/pass\033[0m \033[90m\033[0m Set channel password (owner only)") - print(" \033[36m/transfer\033[0m \033[90m@user\033[0m Transfer ownership (owner only)\n") - + print( + " \033[36m/pass\033[0m \033[90m\033[0m Set channel password (owner only)" + ) + print( + " \033[36m/transfer\033[0m \033[90m@user\033[0m Transfer ownership (owner only)\n" + ) + # Discovery print("\033[38;5;40m▶ Discovery\033[0m") print(" \033[36m/channels\033[0m List all discovered channels") print(" \033[36m/online\033[0m Show who's online") print(" \033[36m/w\033[0m Alias for /online\n") - + # Privacy & Security print("\033[38;5;40m▶ Privacy & Security\033[0m") print(" \033[36m/block\033[0m \033[90m@user\033[0m Block a user") print(" \033[36m/block\033[0m List blocked users") print(" \033[36m/unblock\033[0m \033[90m@user\033[0m Unblock a user\n") - + print("\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m") + def clear_screen(): """Clear the terminal screen""" - print("\033[2J\033[1;1H", end='') + print("\033[2J\033[1;1H", end="") + # Export classes -__all__ = ['ChatMode', 'Public', 'Channel', 'PrivateDM', 'ChatContext', 'format_message_display', 'print_help', 'clear_screen'] \ No newline at end of file +__all__ = [ + "ChatMode", + "Public", + "Channel", + "PrivateDM", + "ChatContext", + "format_message_display", + "print_help", + "clear_screen", +] diff --git a/test_identity_announcement.py b/test_identity_announcement.py index 53d0d41..4ce848b 100644 --- a/test_identity_announcement.py +++ b/test_identity_announcement.py @@ -7,135 +7,145 @@ import time import os -def encode_noise_identity_announcement_binary(peer_id: str, public_key: bytes, - signing_public_key: bytes, nickname: str, - timestamp: int, signature: bytes, - previous_peer_id: str = None) -> bytes: + +def encode_noise_identity_announcement_binary( + peer_id: str, + public_key: bytes, + signing_public_key: bytes, + nickname: str, + timestamp: int, + signature: bytes, + previous_peer_id: str = None, +) -> bytes: """Encode noise identity announcement to binary format matching iOS""" data = bytearray() - + # Flags byte: bit 0 = hasPreviousPeerID flags = 0 if previous_peer_id: flags |= 0x01 data.append(flags) - + # PeerID as 8-byte hex string - peer_data = bytes.fromhex(peer_id.ljust(16, '0')[:16]) # Pad to 8 bytes + peer_data = bytes.fromhex(peer_id.ljust(16, "0")[:16]) # Pad to 8 bytes data.extend(peer_data) - + # PublicKey (length-prefixed) - data.extend(len(public_key).to_bytes(4, 'little')) + data.extend(len(public_key).to_bytes(4, "little")) data.extend(public_key) - + # SigningPublicKey (length-prefixed) - data.extend(len(signing_public_key).to_bytes(4, 'little')) + data.extend(len(signing_public_key).to_bytes(4, "little")) data.extend(signing_public_key) - + # Nickname (length-prefixed string) - nickname_bytes = nickname.encode('utf-8') - data.extend(len(nickname_bytes).to_bytes(4, 'little')) + nickname_bytes = nickname.encode("utf-8") + data.extend(len(nickname_bytes).to_bytes(4, "little")) data.extend(nickname_bytes) - + # Timestamp (8 bytes) - data.extend(timestamp.to_bytes(8, 'little')) - + data.extend(timestamp.to_bytes(8, "little")) + # PreviousPeerID if present if previous_peer_id: - prev_data = bytes.fromhex(previous_peer_id.ljust(16, '0')[:16]) # Pad to 8 bytes + prev_data = bytes.fromhex( + previous_peer_id.ljust(16, "0")[:16] + ) # Pad to 8 bytes data.extend(prev_data) - + # Signature data.extend(signature) - + return bytes(data) + def parse_noise_identity_announcement_binary(data: bytes) -> dict: """Parse binary format noise identity announcement""" if len(data) < 20: # Minimum size check raise ValueError("Data too short for identity announcement") - + offset = 0 - + # Read flags byte flags = data[offset] offset += 1 has_previous_peer_id = (flags & 0x01) != 0 - + # Read peerID (8 bytes) if offset + 8 > len(data): raise ValueError("Insufficient data for peerID") - peer_id_bytes = data[offset:offset + 8] + peer_id_bytes = data[offset : offset + 8] peer_id = peer_id_bytes.hex() offset += 8 - + # Read publicKey (length-prefixed) if offset + 4 > len(data): raise ValueError("Insufficient data for publicKey length") - public_key_len = int.from_bytes(data[offset:offset + 4], 'little') + public_key_len = int.from_bytes(data[offset : offset + 4], "little") offset += 4 - + if offset + public_key_len > len(data): raise ValueError("Insufficient data for publicKey") - public_key = data[offset:offset + public_key_len] + public_key = data[offset : offset + public_key_len] offset += public_key_len - + # Read signingPublicKey (length-prefixed) if offset + 4 > len(data): raise ValueError("Insufficient data for signingPublicKey length") - signing_key_len = int.from_bytes(data[offset:offset + 4], 'little') + signing_key_len = int.from_bytes(data[offset : offset + 4], "little") offset += 4 - + if offset + signing_key_len > len(data): raise ValueError("Insufficient data for signingPublicKey") - signing_public_key = data[offset:offset + signing_key_len] + signing_public_key = data[offset : offset + signing_key_len] offset += signing_key_len - + # Read nickname (length-prefixed string) if offset + 4 > len(data): raise ValueError("Insufficient data for nickname length") - nickname_len = int.from_bytes(data[offset:offset + 4], 'little') + nickname_len = int.from_bytes(data[offset : offset + 4], "little") offset += 4 - + if offset + nickname_len > len(data): raise ValueError("Insufficient data for nickname") - nickname = data[offset:offset + nickname_len].decode('utf-8') + nickname = data[offset : offset + nickname_len].decode("utf-8") offset += nickname_len - + # Read timestamp (8 bytes) if offset + 8 > len(data): raise ValueError("Insufficient data for timestamp") - timestamp = int.from_bytes(data[offset:offset + 8], 'little') + timestamp = int.from_bytes(data[offset : offset + 8], "little") offset += 8 - + # Read previousPeerID if present previous_peer_id = None if has_previous_peer_id: if offset + 8 > len(data): raise ValueError("Insufficient data for previousPeerID") - prev_peer_bytes = data[offset:offset + 8] + prev_peer_bytes = data[offset : offset + 8] previous_peer_id = prev_peer_bytes.hex() offset += 8 - + # Read signature (rest of data) if offset >= len(data): raise ValueError("No signature data") signature = data[offset:] - + return { - 'peerID': peer_id, - 'publicKey': public_key.hex(), - 'signingPublicKey': signing_public_key.hex(), - 'nickname': nickname, - 'timestamp': timestamp, - 'previousPeerID': previous_peer_id, - 'signature': signature.hex() + "peerID": peer_id, + "publicKey": public_key.hex(), + "signingPublicKey": signing_public_key.hex(), + "nickname": nickname, + "timestamp": timestamp, + "previousPeerID": previous_peer_id, + "signature": signature.hex(), } + def test_identity_announcement(): """Test encoding and decoding of identity announcements""" print("Testing Noise Identity Announcement binary format...") - + # Test data peer_id = "7e24c1f633915d33" public_key = os.urandom(32) # 32-byte Curve25519 key @@ -143,7 +153,7 @@ def test_identity_announcement(): nickname = "testuser" timestamp = int(time.time() * 1000) # milliseconds signature = os.urandom(64) # 64-byte signature - + print(f"Original data:") print(f" Peer ID: {peer_id}") print(f" Public Key: {public_key.hex()[:32]}...") @@ -151,19 +161,18 @@ def test_identity_announcement(): print(f" Nickname: {nickname}") print(f" Timestamp: {timestamp}") print(f" Signature: {signature.hex()[:32]}...") - + # Test encoding try: encoded = encode_noise_identity_announcement_binary( - peer_id, public_key, signing_public_key, - nickname, timestamp, signature + peer_id, public_key, signing_public_key, nickname, timestamp, signature ) print(f"\n✓ Encoding successful: {len(encoded)} bytes") print(f" First 32 bytes: {encoded[:32].hex()}") except Exception as e: print(f"✗ Encoding failed: {e}") return False - + # Test decoding try: decoded = parse_noise_identity_announcement_binary(encoded) @@ -177,48 +186,49 @@ def test_identity_announcement(): except Exception as e: print(f"✗ Decoding failed: {e}") return False - + # Verify round-trip print(f"\nVerifying round-trip...") success = True - - if decoded['peerID'] != peer_id: + + if decoded["peerID"] != peer_id: print(f"✗ Peer ID mismatch: {decoded['peerID']} != {peer_id}") success = False - - if decoded['publicKey'] != public_key.hex(): + + if decoded["publicKey"] != public_key.hex(): print(f"✗ Public key mismatch") success = False - - if decoded['signingPublicKey'] != signing_public_key.hex(): + + if decoded["signingPublicKey"] != signing_public_key.hex(): print(f"✗ Signing public key mismatch") success = False - - if decoded['nickname'] != nickname: + + if decoded["nickname"] != nickname: print(f"✗ Nickname mismatch: {decoded['nickname']} != {nickname}") success = False - - if decoded['timestamp'] != timestamp: + + if decoded["timestamp"] != timestamp: print(f"✗ Timestamp mismatch: {decoded['timestamp']} != {timestamp}") success = False - - if decoded['signature'] != signature.hex(): + + if decoded["signature"] != signature.hex(): print(f"✗ Signature mismatch") success = False - - if decoded['previousPeerID'] is not None: + + if decoded["previousPeerID"] is not None: print(f"✗ Previous peer ID should be None, got: {decoded['previousPeerID']}") success = False - + if success: print("✓ Round-trip verification successful!") - + return success + def test_with_previous_peer_id(): """Test with previous peer ID set""" print("\nTesting with previous peer ID...") - + # Test data peer_id = "7e24c1f633915d33" previous_peer_id = "abcd1234567890ef" @@ -227,39 +237,47 @@ def test_with_previous_peer_id(): nickname = "testuser2" timestamp = int(time.time() * 1000) signature = os.urandom(64) - + try: # Encode encoded = encode_noise_identity_announcement_binary( - peer_id, public_key, signing_public_key, - nickname, timestamp, signature, previous_peer_id + peer_id, + public_key, + signing_public_key, + nickname, + timestamp, + signature, + previous_peer_id, ) print(f"✓ Encoding with previous peer ID successful: {len(encoded)} bytes") - + # Decode decoded = parse_noise_identity_announcement_binary(encoded) print(f"✓ Decoding successful") - + # Verify - if decoded['previousPeerID'] != previous_peer_id: - print(f"✗ Previous peer ID mismatch: {decoded['previousPeerID']} != {previous_peer_id}") + if decoded["previousPeerID"] != previous_peer_id: + print( + f"✗ Previous peer ID mismatch: {decoded['previousPeerID']} != {previous_peer_id}" + ) return False - + print(f"✓ Previous peer ID correctly preserved: {decoded['previousPeerID']}") return True - + except Exception as e: print(f"✗ Test with previous peer ID failed: {e}") return False + if __name__ == "__main__": print("=" * 60) print("Noise Identity Announcement Binary Format Test") print("=" * 60) - + success1 = test_identity_announcement() success2 = test_with_previous_peer_id() - + print("\n" + "=" * 60) if success1 and success2: print("🎉 All tests passed!") From 4fc239718252670aa3772e40cf96c49632b654eb Mon Sep 17 00:00:00 2001 From: o-murphy Date: Wed, 16 Jul 2025 01:48:04 +0300 Subject: [PATCH 2/3] Makes project to be packaging-ready * sources refactored to `src-based` layout * fixed bare `except` linter warnings * updated `README.md` * added .gitignore * build system configured * added app entry-point * added dev deps * added CI workflows for `mypy` and `ruff check` * sources formatted with `ruff format` * ChatMode dataclass refactored to Chat dataclass with mode: ChatMode(Enum) field and appropriate factory methods, Codebase cleanup * Logging and CLI args parsing * uses built-in python `logging` * uses argparse for CLI args parsing to setup debug mode, logging, get help and usage * Improved nickname management * added `-n/--name` CLI argument * if name is not provided it would be randomized * added `/h` alias to `/help` * add setuptools-csm for version extraction from git * prevent empty input handling * add /q alias for /exit * revert ver number * Use zlib for compression by default * lz4 should be optional, install via `.[full]` dependency group * improved messages formatting (adjusted to original app format stype) * BitchatMessage.from_bytes and BitchatPacket.from_bytes factory classmethods * replaced `==` equalisation to `is` for enums checking --- .github/workflows/mypy.yml | 28 + .github/workflows/ruff.yml | 30 + .gitignore | 7 + .python-version | 1 + LICENSE | 0 Manifest.in | 4 + README.md | 72 +- VERSION.txt | 1 + compression.py | 29 - pyproject.toml | 87 ++ setup.py | 4 + src/bitchat_python/__init__.py | 0 src/bitchat_python/__main__.py | 14 + src/bitchat_python/_logger.py | 29 + src/bitchat_python/_version.py | 8 + bitchat.py => src/bitchat_python/bitchat.py | 756 ++++++++------- src/bitchat_python/compression.py | 67 ++ .../bitchat_python/encryption.py | 8 +- .../bitchat_python/fragmentation.py | 4 +- .../bitchat_python/persistence.py | 25 +- src/bitchat_python/py.typed | 0 .../bitchat_python/terminal_ux.py | 247 ++--- uv.lock | 913 ++++++++++++++++++ 23 files changed, 1827 insertions(+), 507 deletions(-) create mode 100644 .github/workflows/mypy.yml create mode 100644 .github/workflows/ruff.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 LICENSE create mode 100644 Manifest.in create mode 100644 VERSION.txt delete mode 100644 compression.py create mode 100644 pyproject.toml create mode 100644 setup.py create mode 100644 src/bitchat_python/__init__.py create mode 100644 src/bitchat_python/__main__.py create mode 100644 src/bitchat_python/_logger.py create mode 100644 src/bitchat_python/_version.py rename bitchat.py => src/bitchat_python/bitchat.py (86%) create mode 100644 src/bitchat_python/compression.py rename encryption.py => src/bitchat_python/encryption.py (99%) rename fragmentation.py => src/bitchat_python/fragmentation.py (94%) rename persistence.py => src/bitchat_python/persistence.py (92%) create mode 100644 src/bitchat_python/py.typed rename terminal_ux.py => src/bitchat_python/terminal_ux.py (52%) create mode 100644 uv.lock diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000..33216a7 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,28 @@ +name: Mypy + +on: + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install dependencies + run: uv sync --dev + + - name: Analysing the code with mypy + run: uv run mypy \ No newline at end of file diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..a7f459f --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,30 @@ +name: Ruff + +on: + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install the project + run: | + uv sync --dev + + - name: Analysing the code with ruff + run: | + uv run ruff check \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e66d47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/__pycache__ +**/.idea +**/*.c +**/*.pyd +**/build +**/dist +**/*.egg-info diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/Manifest.in b/Manifest.in new file mode 100644 index 0000000..715e4ec --- /dev/null +++ b/Manifest.in @@ -0,0 +1,4 @@ +prune tests +include LICENSE +include README.md +recursive-include py.typed diff --git a/README.md b/README.md index 886f1ef..e7f8e79 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,47 @@ A Python implementation of the BitChat decentralized, peer-to-peer, encrypted ch * [Setup environment](#clone-and-setup-editable-environment-using-uv) * [Build](#build-sdist-and-wheel) +## Installation +With pip +```shell +pip install git+https://github.com/kaganisildak/bitchat-python +``` + +With [`uv` package and project manager](https://docs.astral.sh/uv/) +```Shell +uv tool install git+https://github.com/kaganisildak/bitchat-python.git +``` + +With [`pipx` standalone python apps panager](https://github.com/pypa/pipx) +```Shell +pipx install git+https://github.com/kaganisildak/bitchat-python.git +``` ## Usage ### Simple start +Installed with `pip`, `uv tool`, `pipx` ```Shell -python3 bitchat.py +bitchat-python ``` +With `uvx` command +> [!NOTE] +> This will only work once the project is published to the PyPI index. +```Shell +uvx bitchat-python +``` +### CLI startup options +```shell + -h, --help show this help message and exit + -d, --debug enable BASIC debug (connection info) + -v, --verbose enable FULL debug (verbose output) + -u, --usage show usage info + -V, --version show program`s version number and exit + --log [LOG] log file path. If no path is provided, logs to 'bitchat.log'. If --log is omitted, no logging occurs. +``` ### BitChat Commands @@ -81,3 +112,42 @@ Privacy & Security Commands * `/unblock @user` : Unblock a user ``` + +## Clone, Develop and Build +> [!TIP] +> [`uv` package and project manager](https://docs.astral.sh/uv/) usage recommended for this step + +### Clone and setup editable environment using `uv` +```Shell +git clone https://github.com/kaganisildak/bitchat-python.git +cd bitchat-python +uv sync --dev +.venv/bin/activate +``` + +### Type checking with +```Shell +uv run mypy +``` + +### Linting and Formatting +Lint +```Shell +uv run ruff check +``` + +Format +```Shell +uv run ruff format +``` + +### Build sdist and wheel +```Shell +uv build +``` + + + + +[//]: # (Old README.md content) +[//]: # (pip install bleak>=0.22.3 cryptography>=44.0.0 lz4>=4.3.3 aioconsole>=0.8.1 pybloom-live>=4.0.0) \ No newline at end of file diff --git a/VERSION.txt b/VERSION.txt new file mode 100644 index 0000000..afaf360 --- /dev/null +++ b/VERSION.txt @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/compression.py b/compression.py deleted file mode 100644 index 5db119b..0000000 --- a/compression.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Tuple - -import lz4.frame - -COMPRESSION_THRESHOLD = 100 - - -def compress_if_beneficial(data: bytes) -> Tuple[bytes, bool]: - """Compress data if it reduces size""" - if len(data) < COMPRESSION_THRESHOLD: - return (data, False) - - compressed = lz4.frame.compress(data) - if len(compressed) < len(data): - return (compressed, True) - else: - return (data, False) - - -def decompress(data: bytes) -> bytes: - """Decompress LZ4 data""" - try: - return lz4.frame.decompress(data) - except Exception as e: - raise ValueError(f"Decompression failed: {e}") - - -# Export functions -__all__ = ["compress_if_beneficial", "decompress", "COMPRESSION_THRESHOLD"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8f120aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["setuptools>=80.1.0", "setuptools-scm[toml]>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "bitchat-python" +authors = [ + { name = "Kağan IŞILDAK" } +] +description = "A Python implementation of the BitChat decentralized, peer-to-peer, encrypted chat application over BLE." +readme = "README.md" +requires-python = ">=3.9" +keywords = ["bitchat", "BLE", "BLE-mesh", "IRC-vibes", "chat-application", "p2p-chat"] +license = { file = "LICENSE" } +classifiers = [ + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python", + "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", +] +dependencies = [ + "aioconsole>=0.8.1", + "bleak>=0.22.3", + "cryptography>=44.0.0", + "pybloom-live>=4.0.0", + +# "lz4>=4.3.3", # uses build-in zlib by default for minimal environments support, where only pure python and built-in modules are supported, a.g. micropython and pythonista +# "typing_extensions>=4.12.2; python_version<'3.11'"", # uncomment to make annotations backward compatibility +] +dynamic = ["version"] + +[project.optional-dependencies] +full = [ + "lz4>=4.3.3", +] + +[project.urls] +"Homepage" = "https://github.com/kaganisildak/bitchat-python" +"Bug Reports" = "https://github.com/kaganisildak/bitchat-python/issues" +"Source" = "https://github.com/kaganisildak/bitchat-python" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["bitchat_python*"] + +[tool.setuptools_scm] +version_file = "VERSION.txt" + +#[tool.setuptools.dynamic] +#version = { file = "VERSION" } + +# default entry point +[project.scripts] +bitchat-python = "bitchat_python.__main__:main" + + +# # uncomment to configure pytest +#[tool.pytest] +#testpaths = ["tests"] + +[tool.mypy] +packages = ["bitchat_python"] + + # uncomment to configure ruff linter/formatter +[tool.ruff] +include = [ + "pyproject.toml", + "src/**/*.py" +] +extend-exclude = ["__init__.py"] + +# uncomment/add deps to allow setup dev dependencies +[dependency-groups] +dev = [ + "mypy>=1.15.0", + "ruff>=0.12.1", +# "pytest>=8.4.1", +# "pytest-cov>=6.2.1", +] diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c25af60 --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +# fallback to `setup.py` +from setuptools import setup, find_packages + +setup() \ No newline at end of file diff --git a/src/bitchat_python/__init__.py b/src/bitchat_python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/bitchat_python/__main__.py b/src/bitchat_python/__main__.py new file mode 100644 index 0000000..f0a4748 --- /dev/null +++ b/src/bitchat_python/__main__.py @@ -0,0 +1,14 @@ +import asyncio + +from bitchat_python import bitchat + + +def main() -> None: + try: + asyncio.run(bitchat.main()) + except KeyboardInterrupt: + print("\n[+] Exiting...") + + +if __name__ == "__main__": + main() diff --git a/src/bitchat_python/_logger.py b/src/bitchat_python/_logger.py new file mode 100644 index 0000000..bd83f29 --- /dev/null +++ b/src/bitchat_python/_logger.py @@ -0,0 +1,29 @@ +import logging + +__all__ = ( + "logger", + "enable_file_logging", +) + +logger = logging.getLogger("bitchat") +logger.setLevel(logging.WARNING) # default log level + +BASIC_FORMAT = "%(levelname)s:%(name)s:%(message)s" +# use on need +# BASIC_FORMAT_WITH_TIME = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +# EXTENDED_FORMAT = "[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s" + +formatter = logging.Formatter(BASIC_FORMAT) + +console_handler = logging.StreamHandler() +console_handler.setLevel(logging.DEBUG) # handler's level should be lowest +console_handler.setFormatter(formatter) +logger.addHandler(console_handler) + + +def enable_file_logging(logfile: str = "bitchat.log", mode: str = "a") -> None: + """Helper to enable logging to a file.""" + file_handler = logging.FileHandler(logfile, mode, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) # handler's level should be lowest + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) diff --git a/src/bitchat_python/_version.py b/src/bitchat_python/_version.py new file mode 100644 index 0000000..042baa0 --- /dev/null +++ b/src/bitchat_python/_version.py @@ -0,0 +1,8 @@ +from importlib import metadata + +try: + __version__ = metadata.version("bitchat-python") +except metadata.PackageNotFoundError: + __version__ = "unknown" + +__all__ = ("__version__",) diff --git a/bitchat.py b/src/bitchat_python/bitchat.py similarity index 86% rename from bitchat.py rename to src/bitchat_python/bitchat.py index 2a24537..7d03c7e 100644 --- a/bitchat.py +++ b/src/bitchat_python/bitchat.py @@ -1,49 +1,45 @@ #!/usr/bin/env python3 +import argparse import asyncio -import sys +import hashlib +import json +import logging import os +import random +import struct import time -import json import uuid -import struct -import hashlib -import random +from dataclasses import dataclass from datetime import datetime -from typing import Optional, Dict, List, Tuple, Set, Union -from dataclasses import dataclass, field from enum import IntEnum -from collections import defaultdict -import logging -import base64 +from pathlib import Path +from random import randint +from typing import Optional, Dict, List, Tuple, Set +import aioconsole # type: ignore[import-untyped] from bleak import BleakClient, BleakScanner, BleakGATTCharacteristic from bleak.backends.device import BLEDevice -import aioconsole -from pybloom_live import BloomFilter +from pybloom_live import BloomFilter # type: ignore[import-untyped] -from encryption import EncryptionService, NoiseError -from compression import compress_if_beneficial, decompress -from fragmentation import Fragment, FragmentType, fragment_payload -from terminal_ux import ( - ChatContext, - ChatMode, - Public, - Channel, - PrivateDM, - format_message_display, - print_help, - clear_screen, -) -from persistence import ( +from bitchat_python._logger import logger, enable_file_logging +from bitchat_python._version import __version__ +from bitchat_python.compression import decompress +from bitchat_python.encryption import EncryptionService, NoiseError +from bitchat_python.persistence import ( AppState, load_state, save_state, encrypt_password, decrypt_password, ) - -# Version -VERSION = "v1.1.0" +from bitchat_python.terminal_ux import ( + ChatContext, + ChatMode, + format_message_display, + print_usage, + clear_screen, + DATETIME_FORMAT, +) # UUIDs BITCHAT_SERVICE_UUID = "f47b5e2d-4a9e-4c5a-9b3f-8e1d2c3a4b5c" @@ -71,34 +67,6 @@ BROADCAST_RECIPIENT = b"\xff" * 8 -# Debug levels -class DebugLevel(IntEnum): - CLEAN = 0 - BASIC = 1 - FULL = 2 - - -DEBUG_LEVEL = DebugLevel.CLEAN - - -def debug_println(*args, **kwargs): - if DEBUG_LEVEL >= DebugLevel.BASIC: - try: - print(*args, **kwargs) - except BlockingIOError: - # Silently ignore blocking errors in debug output - pass - - -def debug_full_println(*args, **kwargs): - if DEBUG_LEVEL >= DebugLevel.FULL: - try: - print(*args, **kwargs) - except BlockingIOError: - # Silently ignore blocking errors in debug output - pass - - # Message types class MessageType(IntEnum): ANNOUNCE = 0x01 @@ -140,6 +108,10 @@ class BitchatPacket: payload: bytes ttl: int + @classmethod + def from_bytes(cls, data: bytes) -> "BitchatPacket": + return parse_bitchat_packet(data) + @dataclass class BitchatMessage: @@ -149,6 +121,10 @@ class BitchatMessage: is_encrypted: bool encrypted_content: Optional[bytes] + @classmethod + def from_bytes(cls, data: bytes) -> "BitchatMessage": + return parse_bitchat_message_payload(data) + @dataclass class DeliveryAck: @@ -194,12 +170,12 @@ def add_fragment( ) -> Optional[Tuple[bytes, str]]: fragment_id_hex = fragment_id.hex() - debug_full_println( + logger.debug( f"[COLLECTOR] Adding fragment {index + 1}/{total} for ID {fragment_id_hex[:8]}" ) if fragment_id_hex not in self.fragments: - debug_full_println( + logger.debug( f"[COLLECTOR] Creating new fragment collection for ID {fragment_id_hex[:8]}" ) self.fragments[fragment_id_hex] = {} @@ -207,25 +183,25 @@ def add_fragment( fragment_map = self.fragments[fragment_id_hex] fragment_map[index] = data - debug_full_println( + logger.debug( f"[COLLECTOR] Fragment {index + 1} stored. Have {len(fragment_map)}/{total} fragments" ) if len(fragment_map) == total: - debug_full_println("[COLLECTOR] ✓ All fragments received! Reassembling...") + logger.debug("[COLLECTOR] ✓ All fragments received! Reassembling...") complete_data = bytearray() for i in range(total): if i in fragment_map: - debug_full_println( + logger.debug( f"[COLLECTOR] Appending fragment {i + 1} ({len(fragment_map[i])} bytes)" ) complete_data.extend(fragment_map[i]) else: - debug_full_println(f"[COLLECTOR] ✗ Missing fragment {i + 1}") + logger.debug(f"[COLLECTOR] ✗ Missing fragment {i + 1}") return None - debug_full_println( + logger.debug( f"[COLLECTOR] ✓ Reassembly complete: {len(complete_data)} bytes total" ) @@ -234,15 +210,15 @@ def add_fragment( del self.fragments[fragment_id_hex] del self.metadata[fragment_id_hex] - return (bytes(complete_data), sender) + return bytes(complete_data), sender return None class BitchatClient: def __init__(self): - self.my_peer_id = os.urandom(8).hex() - self.nickname = "my-python-client" + self.my_peer_id = os.urandom(4).hex() + self.nickname = f"anon{randint(1000, 9999)}" # randomize default nickname self.peers: Dict[str, Peer] = {} self.bloom = BloomFilter(capacity=500, error_rate=0.01) self.processed_messages: Set[str] = set() # Backup for message IDs @@ -278,7 +254,7 @@ def __init__(self): def _on_peer_authenticated(self, peer_id: str, fingerprint: str): """Callback when a peer is authenticated via Noise protocol""" - debug_println( + logger.info( f"[NOISE] Peer {peer_id} authenticated with fingerprint: {fingerprint[:16]}..." ) @@ -287,7 +263,7 @@ def _on_peer_authenticated(self, peer_id: str, fingerprint: str): def _on_handshake_required(self, peer_id: str): """Callback when handshake is required for a peer""" - debug_println(f"[NOISE] Handshake required for peer {peer_id}") + logger.info(f"[NOISE] Handshake required for peer {peer_id}") # The handshake will be initiated when trying to send private messages async def send_pending_private_messages(self, peer_id: str): @@ -299,7 +275,7 @@ async def send_pending_private_messages(self, peer_id: str): if not pending_messages: return - debug_println( + logger.info( f"[NOISE] Sending {len(pending_messages)} pending messages to {peer_id}" ) @@ -312,12 +288,12 @@ async def send_pending_private_messages(self, peer_id: str): # Small delay between messages await asyncio.sleep(0.2) except Exception as e: - debug_println( + logger.debug( f"[NOISE] Failed to send pending message to {peer_id}: {e}" ) # Re-queue the message if it's a temporary error if "blocking" in str(e).lower(): - debug_println(f"[NOISE] Re-queuing message due to BLE congestion") + logger.debug(f"[NOISE] Re-queuing message due to BLE congestion") if peer_id not in self.pending_private_messages: self.pending_private_messages[peer_id] = [] self.pending_private_messages[peer_id].append( @@ -328,21 +304,21 @@ async def send_pending_private_messages(self, peer_id: str): async def find_device(self) -> Optional[BLEDevice]: """Scan for BitChat service""" - debug_println("[1] Scanning for bitchat service...") + logger.info("[1] Scanning for bitchat service...") devices = await BleakScanner.discover( timeout=5.0, service_uuids=[BITCHAT_SERVICE_UUID] ) for device in devices: - debug_full_println(f"Found device: {device.name} - {device.address}") + logger.debug(f"Found device: {device.name} - {device.address}") return device return None def handle_disconnect(self, client: BleakClient): """Handle disconnection from peer""" - print(f"\r\033[K\033[91m✗ Disconnected from BitChat network\033[0m") + print("\r\033[K\033[91m✗ Disconnected from BitChat network\033[0m") print("\033[90m» Scanning for other devices...\033[0m") print("> ", end="", flush=True) @@ -360,7 +336,7 @@ def handle_disconnect(self, client: BleakClient): self.pending_private_messages.clear() # If in a DM, switch to public - if isinstance(self.chat_context.current_mode, PrivateDM): + if self.chat_context.current_chat.mode is ChatMode.PrivateDM: self.chat_context.switch_to_public() # Restart background scanner if not already running @@ -399,7 +375,7 @@ async def connect(self): return False print("\033[90m» Found bitchat service! Connecting...\033[0m") - debug_println("[1] Match Found! Connecting...") + logger.info("[1] Match Found! Connecting...") try: self.client = BleakClient( @@ -416,7 +392,7 @@ async def connect(self): for char in service.characteristics: if char.uuid.lower() == BITCHAT_CHARACTERISTIC_UUID.lower(): self.characteristic = char - debug_println(f"[2] Found characteristic: {char.uuid}") + logger.info(f"[2] Found characteristic: {char.uuid}") break if self.characteristic: break @@ -429,11 +405,11 @@ async def connect(self): self.characteristic, self.notification_handler ) - debug_println("[2] Connection established.") + logger.info("[2] Connection established.") return True except Exception as e: - print(f"\n\033[91m❌ Connection failed\033[0m") + print("\n\033[91m❌ Connection failed\033[0m") print(f"\033[90mReason: {e}\033[0m") print("\033[90mPlease check:\033[0m") print("\033[90m • Bluetooth is enabled\033[0m") @@ -444,7 +420,7 @@ async def connect(self): async def handshake(self): """Perform initial handshake""" - debug_println("[3] Performing handshake...") + logger.info("[3] Performing handshake...") # Load persisted state self.app_state = load_state() @@ -487,12 +463,12 @@ async def handshake(self): signature, ) await self.send_packet(identity_packet) - debug_println("[3] Sent Noise identity announcement (binary format)") + logger.info("[3] Sent Noise identity announcement (binary format)") except Exception as e: - debug_println(f"[3] Failed to send identity announcement: {e}") + logger.info(f"[3] Failed to send identity announcement: {e}") import traceback - debug_println(f"[3] Traceback: {traceback.format_exc()}") + logger.info(f"[3] Traceback: {traceback.format_exc()}") # Fallback to old key exchange handshake_message = self.encryption_service.initiate_handshake( self.my_peer_id @@ -511,9 +487,9 @@ async def handshake(self): ) await self.send_packet(announce_packet) - debug_println("[3] Handshake sent. You can now chat.") + logger.info("[3] Handshake sent. You can now chat.") else: - debug_println("[3] No connection yet. Skipping handshake.") + logger.info("[3] No connection yet. Skipping handshake.") print("\033[90m» Running in offline mode. Waiting for peers...\033[0m") if self.app_state.nickname: @@ -538,23 +514,23 @@ async def handshake(self): ) key = EncryptionService.derive_channel_key(password, channel) self.channel_keys[channel] = key - debug_println( + logger.info( f"[CHANNEL] Restored key for password-protected channel: {channel}" ) except Exception as e: - debug_println(f"[CHANNEL] Failed to restore key for {channel}: {e}") + logger.info(f"[CHANNEL] Failed to restore key for {channel}: {e}") async def send_packet(self, packet: bytes): """Send packet, with fragmentation if needed""" - debug_full_println(f"[RAW SEND] {packet.hex()}") + logger.debug(f"[RAW SEND] {packet.hex()}") if not self.client or not self.characteristic: - debug_println("[!] No connection available. Message queued.") + logger.info("[!] No connection available. Message queued.") # In a real implementation, we might queue messages here return # Check if still connected before sending if not self.client.is_connected: - debug_println("[!] Connection lost. Cannot send packet.") + logger.info("[!] Connection lost. Cannot send packet.") # Trigger disconnection handling if not already done if self.client: self.handle_disconnect(self.client) @@ -573,7 +549,7 @@ async def send_packet(self, packet: bytes): except Exception as e: # Check if this is a connection error if "not connected" in str(e).lower(): - debug_println("[!] Lost connection while sending") + logger.info("[!] Lost connection while sending") if self.client: self.handle_disconnect(self.client) return @@ -584,21 +560,21 @@ async def send_packet(self, packet: bytes): or write_with_response ): try: - debug_println( + logger.info( f"[!] Write blocked, retrying without response after delay" ) await asyncio.sleep(0.1) # Longer delay for retry await self.client.write_gatt_char( self.characteristic, packet, response=False ) - debug_println(f"[!] Retry successful") + logger.info(f"[!] Retry successful") except Exception as e2: if "not connected" in str(e2).lower(): - debug_println("[!] Lost connection while sending") + logger.info("[!] Lost connection while sending") if self.client: self.handle_disconnect(self.client) elif "could not complete without blocking" in str(e2): - debug_println( + logger.info( f"[!] Write still blocked after retry, dropping packet" ) # Don't raise, just log and continue @@ -610,19 +586,17 @@ async def send_packet(self, packet: bytes): async def send_packet_with_fragmentation(self, packet: bytes): """Fragment and send large packets""" if not self.client or not self.characteristic: - debug_println( - "[!] No connection available. Cannot send fragmented message." - ) + logger.info("[!] No connection available. Cannot send fragmented message.") return # Check if still connected if not self.client.is_connected: - debug_println("[!] Connection lost. Cannot send fragmented packet.") + logger.info("[!] Connection lost. Cannot send fragmented packet.") if self.client: self.handle_disconnect(self.client) return - debug_println(f"[FRAG] Original packet size: {len(packet)} bytes") + logger.info(f"[FRAG] Original packet size: {len(packet)} bytes") fragment_size = 150 # Conservative size for iOS BLE chunks = [ @@ -631,8 +605,8 @@ async def send_packet_with_fragmentation(self, packet: bytes): total_fragments = len(chunks) fragment_id = os.urandom(8) - debug_println(f"[FRAG] Fragment ID: {fragment_id.hex()}") - debug_println(f"[FRAG] Total fragments: {total_fragments}") + logger.info(f"[FRAG] Fragment ID: {fragment_id.hex()}") + logger.info(f"[FRAG] Total fragments: {total_fragments}") for index, chunk in enumerate(chunks): if index == 0: @@ -659,13 +633,13 @@ async def send_packet_with_fragmentation(self, packet: bytes): self.characteristic, fragment_packet, response=False ) - debug_println(f"[FRAG] ✓ Fragment {index + 1}/{total_fragments} sent") + logger.info(f"[FRAG] ✓ Fragment {index + 1}/{total_fragments} sent") if index < len(chunks) - 1: await asyncio.sleep(0.02) # 20ms delay except Exception as e: if "not connected" in str(e).lower(): - debug_println( + logger.info( f"[FRAG] Connection lost while sending fragment {index + 1}" ) if self.client: @@ -679,8 +653,8 @@ async def notification_handler(self, sender: BleakGATTCharacteristic, data: byte try: # Enhanced hex logging to match iOS format hex_string = " ".join(f"{b:02X}" for b in data) - debug_full_println(f"[RAW RECV] Received {len(data)} bytes") - debug_full_println(f"[RAW RECV] {hex_string}") + logger.debug(f"[RAW RECV] Received {len(data)} bytes") + logger.debug(f"[RAW RECV] {hex_string}") except BlockingIOError: # If even debug printing fails due to blocking, just silently continue pass @@ -696,36 +670,38 @@ async def notification_handler(self, sender: BleakGATTCharacteristic, data: byte except Exception as e: try: - debug_full_println(f"[ERROR] Failed to parse packet: {e}") + logger.debug(f"[ERROR] Failed to parse packet: {e}") except BlockingIOError: # Silently ignore blocking errors pass async def handle_packet(self, packet: BitchatPacket, raw_data: bytes): """Handle incoming packet""" - if packet.msg_type == MessageType.ANNOUNCE: + if packet.msg_type is MessageType.ANNOUNCE: await self.handle_announce(packet) - elif packet.msg_type == MessageType.MESSAGE: + elif packet.msg_type is MessageType.MESSAGE: await self.handle_message(packet, raw_data) - elif packet.msg_type in [ + elif packet.msg_type in { MessageType.FRAGMENT_START, MessageType.FRAGMENT_CONTINUE, MessageType.FRAGMENT_END, - ]: + }: await self.handle_fragment(packet, raw_data) - elif packet.msg_type == MessageType.KEY_EXCHANGE: + elif packet.msg_type is MessageType.KEY_EXCHANGE: await self.handle_key_exchange(packet) - elif packet.msg_type == MessageType.NOISE_HANDSHAKE_INIT: + elif packet.msg_type is MessageType.NOISE_HANDSHAKE_INIT: await self.handle_noise_handshake_init(packet) - elif packet.msg_type == MessageType.NOISE_HANDSHAKE_RESP: + elif packet.msg_type is MessageType.NOISE_HANDSHAKE_RESP: await self.handle_noise_handshake_resp(packet) - elif packet.msg_type == MessageType.NOISE_ENCRYPTED: + elif packet.msg_type is MessageType.NOISE_ENCRYPTED: await self.handle_noise_encrypted(packet, raw_data) - elif packet.msg_type == MessageType.LEAVE: + elif packet.msg_type is MessageType.LEAVE: await self.handle_leave(packet) - elif packet.msg_type == MessageType.CHANNEL_ANNOUNCE: + elif packet.msg_type is MessageType.CHANNEL_ANNOUNCE: await self.handle_channel_announce(packet) - elif packet.msg_type == MessageType.NOISE_IDENTITY_ANNOUNCE: + elif packet.msg_type is MessageType.DELIVERY_ACK: + await self.handle_delivery_ack(packet, raw_data) + elif packet.msg_type is MessageType.NOISE_IDENTITY_ANNOUNCE: await self.handle_noise_identity_announce(packet) async def handle_announce(self, packet: BitchatPacket): @@ -739,19 +715,20 @@ async def handle_announce(self, packet: BitchatPacket): self.peers[packet.sender_id_str].nickname = peer_nickname if is_new_peer: + time_str = datetime.now().strftime(DATETIME_FORMAT) print( - f"\r\033[K\033[33m{peer_nickname} connected\033[0m\n> ", + f"\r\033[K\033[33m[{time_str}] * @{peer_nickname} connected *\033[0m\n> ", end="", flush=True, ) - debug_println( + logger.info( f"[<-- RECV] Announce: Peer {packet.sender_id_str} is now known as '{peer_nickname}'" ) # Apply tie-breaker logic like iOS client if self.my_peer_id < packet.sender_id_str: # We have lower ID, initiate handshake - debug_println( + logger.info( f"[CRYPTO] Initiating Noise handshake with new peer {packet.sender_id_str} (tie-breaker: we have lower ID)" ) try: @@ -770,11 +747,11 @@ async def handle_announce(self, packet: BitchatPacket): handshake_data[2] = 3 handshake_packet = bytes(handshake_data) await self.send_packet(handshake_packet) - debug_println( + logger.info( f"[NOISE] Sent handshake init to {packet.sender_id_str}, payload size: {len(handshake_message)}" ) except Exception as e: - debug_println(f"[CRYPTO] Failed to initiate handshake: {e}") + logger.info(f"[CRYPTO] Failed to initiate handshake: {e}") # Fallback to old key exchange key_exchange_payload = ( self.encryption_service.get_combined_public_key_data() @@ -785,7 +762,7 @@ async def handle_announce(self, packet: BitchatPacket): await self.send_packet(key_exchange_packet) else: # We have higher ID, send targeted identity announce to prompt them to initiate - debug_println( + logger.info( f"[CRYPTO] Sending targeted identity announce to {packet.sender_id_str} (tie-breaker: they have lower ID)" ) try: @@ -823,7 +800,7 @@ async def handle_announce(self, packet: BitchatPacket): ) await self.send_packet(identity_packet) except Exception as e: - debug_println( + logger.info( f"[CRYPTO] Failed to send targeted identity announce: {e}" ) @@ -832,7 +809,7 @@ async def handle_message(self, packet: BitchatPacket, raw_data: bytes): # Check if sender is blocked fingerprint = self.encryption_service.get_peer_fingerprint(packet.sender_id_str) if fingerprint and fingerprint in self.blocked_peers: - debug_println( + logger.info( f"[BLOCKED] Ignoring message from blocked peer: {packet.sender_id_str}" ) return @@ -851,17 +828,21 @@ async def handle_message(self, packet: BitchatPacket, raw_data: bytes): relay_data[2] = packet.ttl - 1 await self.send_packet(bytes(relay_data)) return + + # Handle private message decryption is_private_message = not is_broadcast and is_for_us decrypted_payload = None + if is_private_message: try: - decrypted_payload = self.encryption_service.decrypt_from_peer( - packet.sender_id_str, packet.payload + decrypted_payload = self.encryption_service.decrypt( + packet.payload, packet.sender_id_str ) - debug_println("[PRIVATE] Successfully decrypted private message!") + logger.info("[PRIVATE] Successfully decrypted private message!") except NoiseError: - debug_println("[PRIVATE] Failed to decrypt private message") + logger.info("[PRIVATE] Failed to decrypt private message") return + # Parse message first to check if it's actually a private message try: if is_private_message and decrypted_payload: @@ -869,6 +850,7 @@ async def handle_message(self, packet: BitchatPacket, raw_data: bytes): message = parse_bitchat_message_payload(unpadded) else: message = parse_bitchat_message_payload(packet.payload) + # Check for duplicates using both bloom filter and set if message.id not in self.processed_messages: # Add to bloom filter and set @@ -897,10 +879,10 @@ async def handle_message(self, packet: BitchatPacket, raw_data: bytes): relay_data[2] = packet.ttl - 1 await self.send_packet(bytes(relay_data)) else: - debug_println(f"[DUPLICATE] Ignoring duplicate message: {message.id}") + logger.info(f"[DUPLICATE] Ignoring duplicate message: {message.id}") except Exception as e: - debug_full_println(f"[ERROR] Failed to parse message: {e}") + logger.debug(f"[ERROR] Failed to parse message: {e}") async def display_message( self, message: BitchatMessage, packet: BitchatPacket, is_private: bool @@ -933,14 +915,14 @@ async def display_message( creator_fingerprint, ) display_content = decrypted - except: - display_content = "[Encrypted message - decryption failed]" + except Exception as e: # possible exceptions: (ValueError, TypeError, EncryptionError, UnicodeDecodeError) + display_content = f"[Encrypted message - decryption failed]: {e}" elif message.is_encrypted: display_content = "[Encrypted message - join channel with password]" # Check for cover traffic if is_private and display_content.startswith(COVER_TRAFFIC_PREFIX): - debug_println(f"[COVER] Discarding dummy message from {sender_nick}") + logger.info(f"[COVER] Discarding dummy message from {sender_nick}") return # Update chat context for private messages @@ -963,7 +945,7 @@ async def display_message( print(f"\r\033[K{display}") - if is_private and not isinstance(self.chat_context.current_mode, PrivateDM): + if is_private and self.chat_context.current_chat.mode is not ChatMode.PrivateDM: print("\033[90m» /reply to respond\033[0m") print("> ", end="", flush=True) @@ -1017,12 +999,10 @@ async def handle_key_exchange(self, packet: BitchatPacket): await self.send_packet(response_packet) if self.encryption_service.is_session_established(packet.sender_id_str): - debug_println( - f"[CRYPTO] Handshake completed with {packet.sender_id_str}" - ) + logger.info(f"[CRYPTO] Handshake completed with {packet.sender_id_str}") # If this is a new peer after reconnection, send our key exchange too if packet.sender_id_str not in self.peers: - debug_println( + logger.info( f"[CRYPTO] Sending key exchange response to new peer {packet.sender_id_str}" ) handshake_message = self.encryption_service.initiate_handshake( @@ -1034,24 +1014,24 @@ async def handle_key_exchange(self, packet: BitchatPacket): await self.send_packet(key_exchange_packet) except Exception as e: - debug_println(f"[CRYPTO] Handshake failed with {packet.sender_id_str}: {e}") + logger.info(f"[CRYPTO] Handshake failed with {packet.sender_id_str}: {e}") async def handle_noise_handshake_init(self, packet: BitchatPacket): """Handle Noise handshake initiation""" - debug_println(f"[NOISE] Received handshake init from {packet.sender_id_str}") - debug_println( + logger.info(f"[NOISE] Received handshake init from {packet.sender_id_str}") + logger.info( f"[NOISE] Recipient ID: {packet.recipient_id_str}, My ID: {self.my_peer_id}" ) # Check if this handshake is for us if packet.recipient_id_str and packet.recipient_id_str != self.my_peer_id: - debug_println(f"[NOISE] Handshake not for us, ignoring") + logger.info(f"[NOISE] Handshake not for us, ignoring") return # Check payload size payload_size = len(packet.payload) - debug_println(f"[NOISE] Handshake payload size: {payload_size} bytes") - debug_println(f"[NOISE] Handshake payload hex: {packet.payload.hex()[:64]}...") + logger.info(f"[NOISE] Handshake payload size: {payload_size} bytes") + logger.info(f"[NOISE] Handshake payload hex: {packet.payload.hex()[:64]}...") try: # Convert bytearray to bytes for encryption service @@ -1063,7 +1043,7 @@ async def handle_noise_handshake_init(self, packet: BitchatPacket): response = self.encryption_service.process_handshake_message( packet.sender_id_str, payload_bytes ) - debug_println( + logger.info( f"[NOISE] process_handshake_message returned: {bool(response)}, response size: {len(response) if response else 0}" ) @@ -1080,14 +1060,12 @@ async def handle_noise_handshake_init(self, packet: BitchatPacket): response_data = bytearray(response_packet) response_data[2] = 3 await self.send_packet(bytes(response_data)) - debug_println( + logger.info( f"[NOISE] Sent handshake response to {packet.sender_id_str}, payload size: {len(response)}" ) if self.encryption_service.is_session_established(packet.sender_id_str): - debug_println( - f"[NOISE] Handshake completed with {packet.sender_id_str}" - ) + logger.info(f"[NOISE] Handshake completed with {packet.sender_id_str}") # Clear handshake attempt time on success (matching Swift) self.handshake_attempt_times.pop(packet.sender_id_str, None) peer_nickname = ( @@ -1104,32 +1082,30 @@ async def handle_noise_handshake_init(self, packet: BitchatPacket): await self.send_pending_private_messages(packet.sender_id_str) except Exception as e: - debug_println( + logger.info( f"[NOISE] Handshake init failed with {packet.sender_id_str}: {e}" ) import traceback - debug_println(f"[NOISE] Handshake error details: {traceback.format_exc()}") + logger.info(f"[NOISE] Handshake error details: {traceback.format_exc()}") # Clear any partial handshake state self.encryption_service.clear_handshake_state(packet.sender_id_str) async def handle_noise_handshake_resp(self, packet: BitchatPacket): """Handle Noise handshake response""" - debug_println( - f"[NOISE] Received handshake response from {packet.sender_id_str}" - ) - debug_println( + logger.info(f"[NOISE] Received handshake response from {packet.sender_id_str}") + logger.info( f"[NOISE] Recipient ID: {packet.recipient_id_str}, My ID: {self.my_peer_id}" ) # Check if this handshake response is for us if packet.recipient_id_str and packet.recipient_id_str != self.my_peer_id: - debug_println(f"[NOISE] Handshake response not for us, ignoring") + logger.info(f"[NOISE] Handshake response not for us, ignoring") return payload_size = len(packet.payload) - debug_println(f"[NOISE] Handshake response payload size: {payload_size} bytes") - debug_println( + logger.info(f"[NOISE] Handshake response payload size: {payload_size} bytes") + logger.info( f"[NOISE] Handshake response payload hex: {packet.payload.hex()[:64]}..." ) @@ -1143,7 +1119,7 @@ async def handle_noise_handshake_resp(self, packet: BitchatPacket): response = self.encryption_service.process_handshake_message( packet.sender_id_str, payload_bytes ) - debug_println( + logger.info( f"[NOISE] process_handshake_message returned: {bool(response)}, response size: {len(response) if response else 0}" ) @@ -1160,14 +1136,12 @@ async def handle_noise_handshake_resp(self, packet: BitchatPacket): final_data = bytearray(final_packet) final_data[2] = 3 await self.send_packet(bytes(final_data)) - debug_println( + logger.info( f"[NOISE] Sent final handshake message to {packet.sender_id_str}, payload size: {len(response)}" ) if self.encryption_service.is_session_established(packet.sender_id_str): - debug_println( - f"[NOISE] Handshake completed with {packet.sender_id_str}" - ) + logger.info(f"[NOISE] Handshake completed with {packet.sender_id_str}") # Clear handshake attempt time on success (matching Swift) self.handshake_attempt_times.pop(packet.sender_id_str, None) peer_nickname = ( @@ -1184,23 +1158,23 @@ async def handle_noise_handshake_resp(self, packet: BitchatPacket): await self.send_pending_private_messages(packet.sender_id_str) except Exception as e: - debug_println( + logger.info( f"[NOISE] Handshake response failed with {packet.sender_id_str}: {e}" ) import traceback - debug_println(f"[NOISE] Handshake error details: {traceback.format_exc()}") + logger.info(f"[NOISE] Handshake error details: {traceback.format_exc()}") # Clear any partial handshake state self.encryption_service.clear_handshake_state(packet.sender_id_str) async def handle_noise_encrypted(self, packet: BitchatPacket, raw_data: bytes): """Handle Noise encrypted message""" - debug_println(f"[NOISE] Received encrypted message from {packet.sender_id_str}") + logger.info(f"[NOISE] Received encrypted message from {packet.sender_id_str}") # Check if sender is blocked fingerprint = self.encryption_service.get_peer_fingerprint(packet.sender_id_str) if fingerprint and fingerprint in self.blocked_peers: - debug_println( + logger.info( f"[BLOCKED] Ignoring encrypted message from blocked peer: {packet.sender_id_str}" ) return @@ -1217,7 +1191,7 @@ async def handle_noise_encrypted(self, packet: BitchatPacket, raw_data: bytes): decrypted_payload = self.encryption_service.decrypt_from_peer( packet.sender_id_str, payload_bytes ) - debug_println( + logger.info( f"[NOISE] Successfully decrypted {len(decrypted_payload)} bytes from {packet.sender_id_str}" ) @@ -1230,7 +1204,7 @@ async def handle_noise_encrypted(self, packet: BitchatPacket, raw_data: bytes): # Parse the decrypted data as a complete BitchatPacket inner_packet = parse_bitchat_packet(decrypted_payload) if inner_packet: - debug_println( + logger.info( f"[NOISE] Decrypted inner packet: type={inner_packet.msg_type.name if hasattr(inner_packet.msg_type, 'name') else inner_packet.msg_type}, sender={inner_packet.sender_id_str}" ) @@ -1255,27 +1229,27 @@ async def handle_noise_encrypted(self, packet: BitchatPacket, raw_data: bytes): message.id, packet.sender_id_str, True ) else: - debug_println( + logger.info( f"[DUPLICATE] Ignoring duplicate encrypted message: {message.id}" ) except Exception as e: - debug_println( + logger.info( f"[NOISE] Failed to parse inner message payload: {e}" ) else: - debug_println( + logger.info( f"[NOISE] Unexpected inner packet type: {inner_packet.msg_type}, expected MESSAGE" ) # Handle other types of inner packets if needed await self.handle_packet(inner_packet, decrypted_payload) else: - debug_println( + logger.info( f"[NOISE] Failed to parse decrypted data as BitchatPacket" ) else: # Handle non-BitchatPacket data (likely JSON acknowledgments or receipts) - debug_println( + logger.info( f"[NOISE] Decrypted data does not start with version 1, likely acknowledgment/receipt" ) try: @@ -1285,45 +1259,43 @@ async def handle_noise_encrypted(self, packet: BitchatPacket, raw_data: bytes): import json ack_data = json.loads(data_str) - debug_println( - f"[NOISE] Received acknowledgment: {ack_data}" - ) + logger.info(f"[NOISE] Received acknowledgment: {ack_data}") # Handle acknowledgment data if needed else: - debug_println(f"[NOISE] Unknown decrypted data format") + logger.info(f"[NOISE] Unknown decrypted data format") except Exception as json_e: - debug_println( + logger.info( f"[NOISE] Failed to parse as JSON acknowledgment: {json_e}" ) except Exception as e: - debug_println(f"[NOISE] Error parsing decrypted inner packet: {e}") + logger.info(f"[NOISE] Error parsing decrypted inner packet: {e}") # Log the first few bytes for debugging preview = ( decrypted_payload[:50] if len(decrypted_payload) >= 50 else decrypted_payload ) - debug_println( + logger.info( f"[NOISE] Decrypted data preview: {preview.hex() if isinstance(preview, bytes) else preview}" ) except Exception as e: - debug_println( + logger.info( f"[NOISE] Failed to decrypt message from {packet.sender_id_str}: {e}" ) # Check if we have a session with this peer if not self.encryption_service.is_session_established(packet.sender_id_str): - debug_println( + logger.info( f"[NOISE] No session established with {packet.sender_id_str}" ) else: - debug_println( + logger.info( f"[NOISE] Session exists but decryption failed - possible key sync issue" ) # If it's an InvalidTag error, it might be a nonce sync issue if "InvalidTag" in str(e): - debug_println( + logger.info( f"[NOISE] InvalidTag suggests nonce desync - this could be from iOS sending acknowledgments" ) # Don't reset the session here, just log it @@ -1342,8 +1314,8 @@ async def handle_leave(self, packet: BitchatPacket): ) if ( - isinstance(self.chat_context.current_mode, Channel) - and self.chat_context.current_mode.name == channel + self.chat_context.current_chat.mode is ChatMode.Channel + and self.chat_context.current_chat.name == channel ): print( f"\r\033[K\033[90m« {sender_nick} left {channel}\033[0m\n> ", @@ -1351,13 +1323,14 @@ async def handle_leave(self, packet: BitchatPacket): flush=True, ) - debug_println(f"[<-- RECV] {sender_nick} left channel {channel}") + logger.info(f"[<-- RECV] {sender_nick} left channel {channel}") else: # Peer disconnect disconnected_peer = self.peers.pop(packet.sender_id_str, None) if disconnected_peer and disconnected_peer.nickname: + time_str = datetime.now().strftime(DATETIME_FORMAT) print( - f"\r\033[K\033[33m{disconnected_peer.nickname} disconnected\033[0m\n> ", + f"\r\033[K\033[33m[{time_str}] * @{disconnected_peer.nickname} disconnected *\033[0m\n> ", end="", flush=True, ) @@ -1372,14 +1345,14 @@ async def handle_leave(self, packet: BitchatPacket): # Clear encryption session for this peer self.encryption_service.remove_session(packet.sender_id_str) - debug_println( + logger.info( f"[NOISE] Cleared session for disconnected peer {packet.sender_id_str}" ) # If we're in a DM with this peer, switch to public if ( - isinstance(self.chat_context.current_mode, PrivateDM) - and self.chat_context.current_mode.peer_id == packet.sender_id_str + self.chat_context.current_chat.mode is ChatMode.PrivateDM + and self.chat_context.current_chat.peer_id == packet.sender_id_str ): self.chat_context.switch_to_public() print( @@ -1388,7 +1361,7 @@ async def handle_leave(self, packet: BitchatPacket): flush=True, ) - debug_println( + logger.info( f"[<-- RECV] Peer {packet.sender_id_str} ({payload_str}) has left" ) @@ -1411,7 +1384,7 @@ async def handle_channel_announce(self, packet: BitchatPacket): creator_id = parts[2] key_commitment = parts[3] if len(parts) > 3 else "" - debug_println( + logger.info( f"[<-- RECV] Channel announce: {channel} (protected: {is_protected}, owner: {creator_id})" ) @@ -1448,8 +1421,10 @@ async def handle_delivery_ack(self, packet: BitchatPacket, raw_data: bytes): ack_payload = self.encryption_service.decrypt_from_peer( packet.sender_id_str, packet.payload ) - except: - pass + except ( + Exception + ) as e: # possible exceptions (ValueError, TypeError, EncryptionError) + logger.info(f"[DECRYPT] Error occurred during decryption: {e}") # Parse ACK try: @@ -1465,13 +1440,13 @@ async def handle_delivery_ack(self, packet: BitchatPacket, raw_data: bytes): if self.delivery_tracker.mark_delivered(ack.original_message_id): print( - f"\r\u001b[K\u001b[90m✓ Delivered to {ack.recipient_nickname}\u001b[0m\n> ", + f"\r\u001b[K\u001b[90m✓ Delivered to @{ack.recipient_nickname}\u001b[0m\n> ", end="", flush=True, ) except Exception as e: - debug_println(f"[ACK] Failed to parse delivery ACK: {e}") + logger.info(f"[ACK] Failed to parse delivery ACK: {e}") elif packet.ttl > 1: # Relay ACK @@ -1483,7 +1458,7 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): """Handle Noise identity announcement""" try: sender_id = packet.sender_id_str - debug_println(f"[NOISE] Received identity announcement from {sender_id}") + logger.info(f"[NOISE] Received identity announcement from {sender_id}") # Skip if it's from ourselves if sender_id == self.my_peer_id: @@ -1498,7 +1473,7 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): packet.payload ) except Exception as be: - debug_println(f"[NOISE] Binary decode failed: {be}") + logger.info(f"[NOISE] Binary decode failed: {be}") # Try JSON fallback for compatibility try: announcement_data = json.loads(packet.payload.decode("utf-8")) @@ -1513,14 +1488,14 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): "signature": announcement_data.get("signature", ""), } except Exception as je: - debug_println(f"[NOISE] JSON decode also failed: {je}") - debug_println( + logger.info(f"[NOISE] JSON decode also failed: {je}") + logger.info( f"[NOISE] Raw payload (first 32 bytes): {packet.payload[:32].hex()}" ) return if not announcement: - debug_println( + logger.info( f"[NOISE] Failed to decode identity announcement from {sender_id}" ) return @@ -1528,7 +1503,7 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): peer_id = announcement["peerID"] nickname = announcement["nickname"] - debug_println(f"[NOISE] Identity announcement: {peer_id} -> {nickname}") + logger.info(f"[NOISE] Identity announcement: {peer_id} -> {nickname}") # Check if this is a new peer is_new_peer = peer_id not in self.peers @@ -1544,13 +1519,13 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): end="", flush=True, ) - debug_println( + logger.info( f"[<-- RECV] Announce: Peer {peer_id} is now known as '{nickname}'" ) # Check if we should initiate handshake (lexicographic comparison) if self.my_peer_id < peer_id: - debug_println(f"[NOISE] We should initiate handshake with {peer_id}") + logger.info(f"[NOISE] We should initiate handshake with {peer_id}") # Check if we already have a session or ongoing handshake if not self.encryption_service.is_session_established(peer_id): try: @@ -1569,17 +1544,17 @@ async def handle_noise_identity_announce(self, packet: BitchatPacket): handshake_data[2] = 3 handshake_packet = bytes(handshake_data) await self.send_packet(handshake_packet) - debug_println(f"[NOISE] Initiated handshake with {peer_id}") + logger.info(f"[NOISE] Initiated handshake with {peer_id}") except Exception as e: - debug_println(f"[NOISE] Failed to initiate handshake: {e}") + logger.info(f"[NOISE] Failed to initiate handshake: {e}") else: - debug_println(f"[NOISE] Waiting for {peer_id} to initiate handshake") + logger.info(f"[NOISE] Waiting for {peer_id} to initiate handshake") except Exception as e: - debug_println(f"[NOISE] Error handling identity announcement: {e}") + logger.info(f"[NOISE] Error handling identity announcement: {e}") import traceback - debug_println( + logger.info( f"[NOISE] Identity announce error details: {traceback.format_exc()}" ) @@ -1588,54 +1563,54 @@ def parse_noise_identity_announcement_binary(self, data: bytes) -> dict: try: offset = 0 - debug_println( + logger.info( f"[NOISE] Parsing binary announcement, total length: {len(data)}" ) - debug_println(f"[NOISE] Raw data (hex): {data.hex()}") + logger.info(f"[NOISE] Raw data (hex): {data.hex()}") # Read flags byte if offset >= len(data): - debug_println("[NOISE] Error: Not enough data for flags") + logger.info("[NOISE] Error: Not enough data for flags") return None flags = data[offset] offset += 1 - debug_println(f"[NOISE] Flags: 0x{flags:02x}") + logger.info(f"[NOISE] Flags: 0x{flags:02x}") # Check if previousPeerID is present (flag bit 0) has_previous_peer_id = (flags & 0x01) != 0 - debug_println(f"[NOISE] Has previous peer ID: {has_previous_peer_id}") + logger.info(f"[NOISE] Has previous peer ID: {has_previous_peer_id}") # Read peerID (8 bytes) if offset + 8 > len(data): - debug_println( + logger.info( f"[NOISE] Error: Not enough data for peerID, need 8 bytes, have {len(data) - offset}" ) return None peer_id = data[offset : offset + 8].hex() offset += 8 - debug_println(f"[NOISE] Peer ID: {peer_id}") + logger.info(f"[NOISE] Peer ID: {peer_id}") # Read publicKey using appendData format (1-byte length prefix for 255 max) if offset >= len(data): - debug_println("[NOISE] Error: Not enough data for publicKey length") + logger.info("[NOISE] Error: Not enough data for publicKey length") return None pub_key_len = data[offset] offset += 1 if offset + pub_key_len > len(data): - debug_println( + logger.info( f"[NOISE] Error: Not enough data for publicKey, need {pub_key_len} bytes, have {len(data) - offset}" ) return None public_key = data[offset : offset + pub_key_len] offset += pub_key_len - debug_println( + logger.info( f"[NOISE] Public key length: {pub_key_len}, key: {public_key.hex()}" ) # Read signingPublicKey using appendData format (1-byte length prefix for 255 max) if offset >= len(data): - debug_println( + logger.info( "[NOISE] Error: Not enough data for signingPublicKey length" ) return None @@ -1643,78 +1618,78 @@ def parse_noise_identity_announcement_binary(self, data: bytes) -> dict: offset += 1 if offset + signing_key_len > len(data): - debug_println( + logger.info( f"[NOISE] Error: Not enough data for signingPublicKey, need {signing_key_len} bytes, have {len(data) - offset}" ) return None signing_public_key = data[offset : offset + signing_key_len] offset += signing_key_len - debug_println( + logger.info( f"[NOISE] Signing public key length: {signing_key_len}, key: {signing_public_key.hex()}" ) # Read nickname using appendString format (1-byte length prefix for 255 max) if offset >= len(data): - debug_println("[NOISE] Error: Not enough data for nickname length") + logger.info("[NOISE] Error: Not enough data for nickname length") return None nickname_len = data[offset] offset += 1 - debug_println(f"[NOISE] Nickname length: {nickname_len}") + logger.info(f"[NOISE] Nickname length: {nickname_len}") nickname = "" if nickname_len > 0: if offset + nickname_len > len(data): - debug_println( + logger.info( f"[NOISE] Error: Not enough data for nickname, need {nickname_len} bytes, have {len(data) - offset}" ) return None nickname_bytes = data[offset : offset + nickname_len] offset += nickname_len nickname = nickname_bytes.decode("utf-8") - debug_println(f"[NOISE] Nickname: '{nickname}'") + logger.info(f"[NOISE] Nickname: '{nickname}'") else: - debug_println("[NOISE] Nickname: (empty)") + logger.info("[NOISE] Nickname: (empty)") # Read timestamp using appendDate format (8-byte UInt64 in milliseconds, big-endian) if offset + 8 > len(data): - debug_println( + logger.info( f"[NOISE] Error: Not enough data for timestamp, need 8 bytes, have {len(data) - offset}" ) return None timestamp_ms = int.from_bytes(data[offset : offset + 8], byteorder="big") offset += 8 timestamp = timestamp_ms / 1000.0 # Convert from milliseconds to seconds - debug_println(f"[NOISE] Timestamp: {timestamp} ({timestamp_ms}ms)") + logger.info(f"[NOISE] Timestamp: {timestamp} ({timestamp_ms}ms)") # Read previousPeerID if present (8 bytes) previous_peer_id = None if has_previous_peer_id: if offset + 8 > len(data): - debug_println("[NOISE] Error: Not enough data for previousPeerID") + logger.info("[NOISE] Error: Not enough data for previousPeerID") return None previous_peer_id = data[offset : offset + 8].hex() offset += 8 - debug_println(f"[NOISE] Previous peer ID: {previous_peer_id}") + logger.info(f"[NOISE] Previous peer ID: {previous_peer_id}") # Read signature using appendData format (1-byte length prefix for 255 max) if offset >= len(data): - debug_println("[NOISE] Error: Not enough data for signature length") + logger.info("[NOISE] Error: Not enough data for signature length") return None signature_len = data[offset] offset += 1 if offset + signature_len > len(data): - debug_println( + logger.info( f"[NOISE] Error: Not enough data for signature, need {signature_len} bytes, have {len(data) - offset}" ) return None signature = data[offset : offset + signature_len] offset += signature_len - debug_println( + logger.info( f"[NOISE] Signature length: {signature_len}, sig: {signature.hex()}" ) - debug_println( + logger.info( f"[NOISE] Total parsed {offset} bytes out of {len(data)} available" ) @@ -1730,10 +1705,10 @@ def parse_noise_identity_announcement_binary(self, data: bytes) -> dict: } except Exception as e: - debug_println(f"[NOISE] Error parsing binary announcement: {e}") + logger.info(f"[NOISE] Error parsing binary announcement: {e}") import traceback - debug_println( + logger.info( f"[NOISE] Binary parser error details: {traceback.format_exc()}" ) return None @@ -1800,7 +1775,7 @@ async def send_delivery_ack( if not self.delivery_tracker.should_send_ack(ack_id): return - debug_println(f"[ACK] Sending delivery ACK for message {message_id}") + logger.info(f"[ACK] Sending delivery ACK for message {message_id}") ack = DeliveryAck( message_id, @@ -1826,8 +1801,10 @@ async def send_delivery_ack( if is_private: try: ack_payload = self.encryption_service.encrypt(ack_payload, sender_id) - except: - pass + except ( + Exception + ) as e: # possible exceptions: (ValueError, TypeError, EncryptionError) + logger.info(f"[ENCRYPT] Error occurred during encryption: {e}") # Send ACK packet ack_packet = create_bitchat_packet_with_recipient( @@ -1853,7 +1830,7 @@ async def send_channel_announce( packet_data = bytearray(packet) packet_data[2] = 5 - debug_println(f"[CHANNEL] Sending channel announce for {channel}") + logger.info(f"[CHANNEL] Sending channel announce for {channel}") await self.send_packet(bytes(packet_data)) async def save_app_state(self): @@ -1870,23 +1847,36 @@ async def save_app_state(self): except Exception as e: logging.error(f"Failed to save state: {e}") + async def update_name(self, new_name: str): + self.nickname = new_name + announce_packet = create_bitchat_packet( + self.my_peer_id, MessageType.ANNOUNCE, self.nickname.encode() + ) + await self.send_packet(announce_packet) + print(f"\033[90m» Nickname changed to: {self.nickname}\033[0m") + await self.save_app_state() + async def handle_user_input(self, line: str): """Handle user input commands and messages""" + if not line: + # ignore empty input + return + # Number switching if len(line) == 1 and line.isdigit(): num = int(line) if self.chat_context.switch_to_number(num): - debug_println(self.chat_context.get_status_line()) + logger.info(self.chat_context.get_status_line()) else: print("» Invalid conversation number") return # Commands - if line == "/help": - print_help() + if line in {"/help", "/h"}: + print_usage() return - if line == "/exit": + if line in {"/exit", "/q"}: # Send leave notification if connected if self.client and self.client.is_connected: leave_packet = create_bitchat_packet( @@ -1899,6 +1889,12 @@ async def handle_user_input(self, line: str): self.running = False return + if line == "/me": + print( + f"\033[K\033[33m» Your Nickname is: {self.nickname}, Your ID: {self.my_peer_id}\033[0m" + ) + return + if line.startswith("/name "): new_name = line[6:].strip() if not new_name: @@ -1916,13 +1912,7 @@ async def handle_user_input(self, line: str): print("\033[93m⚠ Reserved nickname\033[0m") print("\033[90mThis nickname is reserved and cannot be used.\033[0m") else: - self.nickname = new_name - announce_packet = create_bitchat_packet( - self.my_peer_id, MessageType.ANNOUNCE, self.nickname.encode() - ) - await self.send_packet(announce_packet) - print(f"\033[90m» Nickname changed to: {self.nickname}\033[0m") - await self.save_app_state() + await self.update_name(new_name) return if line == "/list": @@ -1935,7 +1925,7 @@ async def handle_user_input(self, line: str): if switch_input.strip().isdigit(): num = int(switch_input.strip()) if self.chat_context.switch_to_number(num): - debug_println(self.chat_context.get_status_line()) + logger.info(self.chat_context.get_status_line()) else: print("» Invalid selection") return @@ -1946,10 +1936,10 @@ async def handle_user_input(self, line: str): if line == "/public": self.chat_context.switch_to_public() - debug_println(self.chat_context.get_status_line()) + logger.info(self.chat_context.get_status_line()) return - if line in ["/online", "/w"]: + if line in {"/online", "/w"}: if not self.client or not self.client.is_connected: print("» You're not connected to any peers yet.") print("\033[90mWaiting for other BitChat devices...\033[0m") @@ -2050,9 +2040,9 @@ async def handle_user_input(self, line: str): print_banner() mode_name = { ChatMode.Public: "public chat", - ChatMode.Channel: f"channel {self.chat_context.current_mode.name}", - ChatMode.PrivateDM: f"DM with {self.chat_context.current_mode.nickname}", - }.get(type(self.chat_context.current_mode), "unknown") + ChatMode.Channel: f"channel {self.chat_context.current_chat.name}", + ChatMode.PrivateDM: f"DM with {self.chat_context.current_chat.name}", + }.get(self.chat_context.current_chat.mode, "unknown") print(f"» Cleared {mode_name}") print("> ", end="", flush=True) return @@ -2065,7 +2055,7 @@ async def handle_user_input(self, line: str): if self.chat_context.last_private_sender: peer_id, nickname = self.chat_context.last_private_sender self.chat_context.enter_dm_mode(nickname, peer_id) - debug_println(self.chat_context.get_status_line()) + logger.info(self.chat_context.get_status_line()) else: print("» No private messages received yet.") return @@ -2098,11 +2088,11 @@ async def handle_user_input(self, line: str): return # Regular message - check mode - if isinstance(self.chat_context.current_mode, PrivateDM): + if self.chat_context.current_chat.mode is ChatMode.PrivateDM: await self.send_private_message( line, - self.chat_context.current_mode.peer_id, - self.chat_context.current_mode.nickname, + self.chat_context.current_chat.peer_id, + self.chat_context.current_chat.name, ) else: # Check if we're connected before sending @@ -2187,7 +2177,7 @@ async def handle_join_channel(self, line: str): self.app_state.encrypted_channel_passwords[channel_name] = encrypted await self.save_app_state() except Exception as e: - debug_println(f"[CHANNEL] Failed to encrypt password: {e}") + logger.info(f"[CHANNEL] Failed to encrypt password: {e}") self.chat_context.switch_to_channel_silent(channel_name) print("\r\033[K\033[90m─────────────────────────\033[0m") @@ -2226,7 +2216,7 @@ async def handle_join_channel(self, line: str): self.channel_keys.pop(channel_name, None) print("> ", end="", flush=True) - debug_println(self.chat_context.get_status_line()) + logger.info(self.chat_context.get_status_line()) async def handle_dm_command(self, line: str): """Handle /dm command""" @@ -2265,7 +2255,7 @@ async def handle_dm_command(self, line: str): else: # Enter DM mode self.chat_context.enter_dm_mode(target_nickname, target_peer_id) - debug_println(self.chat_context.get_status_line()) + logger.info(self.chat_context.get_status_line()) async def handle_block_command(self, line: str): """Handle /block command""" @@ -2366,8 +2356,8 @@ async def handle_unblock_command(self, line: str): async def handle_leave_command(self): """Handle /leave command""" - if isinstance(self.chat_context.current_mode, Channel): - channel = self.chat_context.current_mode.name + if self.chat_context.current_chat.mode is ChatMode.Channel: + channel = self.chat_context.current_chat.name # Send leave notification leave_payload = channel.encode() @@ -2400,11 +2390,11 @@ async def handle_leave_command(self): async def handle_pass_command(self, line: str): """Handle /pass command""" - if not isinstance(self.chat_context.current_mode, Channel): + if self.chat_context.current_chat.mode is not ChatMode.Channel: print("» You must be in a channel to use /pass.") return - channel = self.chat_context.current_mode.name + channel = self.chat_context.current_chat.name parts = line.split(maxsplit=1) if len(parts) < 2: @@ -2428,7 +2418,7 @@ async def handle_pass_command(self, line: str): # Claim ownership if no owner if not owner: self.channel_creators[channel] = self.my_peer_id - debug_println(f"[CHANNEL] Claiming ownership of {channel}") + logger.info(f"[CHANNEL] Claiming ownership of {channel}") # Update password old_key = self.channel_keys.get(channel) @@ -2443,7 +2433,7 @@ async def handle_pass_command(self, line: str): encrypted = encrypt_password(new_password, self.app_state.identity_key) self.app_state.encrypted_channel_passwords[channel] = encrypted except Exception as e: - debug_println(f"[CHANNEL] Failed to encrypt password: {e}") + logger.info(f"[CHANNEL] Failed to encrypt password: {e}") # Calculate commitment commitment_hex = hashlib.sha256(new_key).hexdigest() @@ -2455,9 +2445,9 @@ async def handle_pass_command(self, line: str): "🔐 Password changed by channel owner. Please update your password." ) try: - encrypted_notify = self.encryption_service.encrypt_with_key( - notify_msg.encode(), old_key - ) + # encrypted_notify = FIXME: encrypted_notify var was never used + self.encryption_service.encrypt_with_key(notify_msg.encode(), old_key) + notify_payload, _ = create_encrypted_channel_message_payload( self.nickname, notify_msg, @@ -2470,8 +2460,10 @@ async def handle_pass_command(self, line: str): self.my_peer_id, MessageType.MESSAGE, notify_payload ) await self.send_packet(notify_packet) - except: - pass + except ( + Exception + ) as e: # possible exceptions: (TypeError, ValueError, EncryptionError) + logger.info(f"[CHANNEL] Failed to encrypt password: {e}") # Send channel announce await self.send_channel_announce(channel, True, commitment_hex) @@ -2498,11 +2490,11 @@ async def handle_pass_command(self, line: str): async def handle_transfer_command(self, line: str): """Handle /transfer command""" - if not isinstance(self.chat_context.current_mode, Channel): + if self.chat_context.current_chat.mode is not ChatMode.Channel: print("» You must be in a channel to use /transfer.") return - channel = self.chat_context.current_mode.name + channel = self.chat_context.current_chat.name parts = line.split() if len(parts) != 2: @@ -2548,6 +2540,7 @@ async def handle_transfer_command(self, line: str): async def send_public_message(self, content: str): """Send a public or channel message""" + if not self.client or not self.characteristic: print("\033[93m⚠ Not connected to any peers yet.\033[0m") print( @@ -2556,8 +2549,8 @@ async def send_public_message(self, content: str): return current_channel = None - if isinstance(self.chat_context.current_mode, Channel): - current_channel = self.chat_context.current_mode.name + if self.chat_context.current_chat.mode is ChatMode.Channel: + current_channel = self.chat_context.current_chat.name # Check if password protected if ( @@ -2637,7 +2630,7 @@ async def send_private_message( # Check if we have a Noise session with this peer if not self.encryption_service.is_session_established(target_peer_id): - debug_println( + logger.info( f"[NOISE] No session with {target_peer_id}, need to establish handshake" ) @@ -2648,12 +2641,12 @@ async def send_private_message( self.pending_private_messages[target_peer_id].append( (content, target_nickname, msg_id) ) - debug_println( + logger.info( f"[NOISE] Queued private message for {target_peer_id}, {len(self.pending_private_messages[target_peer_id])} messages pending" ) # Always initiate handshake for private messages since user explicitly requested it - debug_println( + logger.info( f"[NOISE] Initiating handshake with {target_peer_id} for private message" ) @@ -2662,7 +2655,7 @@ async def send_private_message( if target_peer_id in self.handshake_attempt_times: last_attempt = self.handshake_attempt_times[target_peer_id] if current_time - last_attempt < self.handshake_timeout: - debug_println( + logger.info( f"[NOISE] Skipping handshake with {target_peer_id} - too recent (last attempt {current_time - last_attempt:.1f}s ago)" ) print( @@ -2689,11 +2682,11 @@ async def send_private_message( handshake_data[2] = 3 handshake_packet = bytes(handshake_data) await self.send_packet(handshake_packet) - debug_println( + logger.info( f"[NOISE] Sent handshake init to {target_peer_id}, payload size: {len(handshake_message)}" ) except Exception as e: - debug_println(f"[NOISE] Failed to initiate handshake: {e}") + logger.info(f"[NOISE] Failed to initiate handshake: {e}") # Clear the attempt time on failure so we can retry sooner self.handshake_attempt_times.pop(target_peer_id, None) print( @@ -2709,15 +2702,15 @@ async def send_private_message( ) return - debug_println(f"[PRIVATE] Sending encrypted message to {target_nickname}") + logger.info(f"[PRIVATE] Sending encrypted message to {target_nickname}") # Create message payload - don't set is_encrypted=True since encryption happens at Noise layer payload, message_id = create_bitchat_message_payload_full( self.nickname, content, None, True, self.my_peer_id, False, None ) - debug_println(f"[PRIVATE] Created message payload: {len(payload)} bytes") - debug_println(f"[PRIVATE] Message payload hex: {payload.hex()}") + logger.info(f"[PRIVATE] Created message payload: {len(payload)} bytes") + logger.info(f"[PRIVATE] Message payload hex: {payload.hex()}") # Track for delivery self.delivery_tracker.track_message(message_id, content, True) @@ -2733,14 +2726,14 @@ async def send_private_message( inner_packet_data[2] = 7 # TTL for inner packet inner_packet = bytes(inner_packet_data) - debug_println(f"[PRIVATE] Created inner packet: {len(inner_packet)} bytes") + logger.info(f"[PRIVATE] Created inner packet: {len(inner_packet)} bytes") try: # Encrypt the ENTIRE inner packet using Noise (matching Swift) encrypted = self.encryption_service.encrypt_for_peer( target_peer_id, inner_packet ) - debug_println(f"[PRIVATE] Encrypted inner packet: {len(encrypted)} bytes") + logger.info(f"[PRIVATE] Encrypted inner packet: {len(encrypted)} bytes") # Create outer Noise encrypted packet packet = create_bitchat_packet_with_recipient( @@ -2772,7 +2765,7 @@ async def send_private_message( except Exception as send_error: # Handle BLE send errors specifically if "could not complete without blocking" in str(send_error): - debug_println( + logger.info( f"[PRIVATE] BLE write blocked, will retry after longer delay" ) try: @@ -2800,10 +2793,10 @@ async def send_private_message( self.nickname, ) print(f"\x1b[1A\r\033[K{display}") - debug_println(f"[PRIVATE] Message sent successfully on retry") + logger.info(f"[PRIVATE] Message sent successfully on retry") except Exception as retry_error: - debug_println(f"[PRIVATE] Retry also failed: {retry_error}") + logger.info(f"[PRIVATE] Retry also failed: {retry_error}") try: print( f"\033[91m✗ Failed to send message (BLE congestion)\033[0m" @@ -2816,9 +2809,9 @@ async def send_private_message( raise send_error except Exception as e: - debug_println(f"[PRIVATE] Failed to encrypt private message: {e}") + logger.info(f"[PRIVATE] Failed to encrypt private message: {e}") print( - f"\033[91m✗ Failed to send encrypted message to {target_nickname}\033[0m" + f"\033[91m✗ Failed to send encrypted message to @{target_nickname}\033[0m" ) print(f"\033[90m» Error: {e}\033[0m") @@ -2832,14 +2825,14 @@ async def background_scanner(self): if current_time - last_cleanup > 300: # 5 minutes self.encryption_service.cleanup_old_sessions() last_cleanup = current_time - debug_println(f"[CLEANUP] Cleaned up old encryption sessions") + logger.info(f"[CLEANUP] Cleaned up old encryption sessions") if not self.client or not self.client.is_connected: # Try to find and connect to a peer device = await self.find_device() if device: print( - f"\r\033[K\033[92m» Found a BitChat device! Connecting...\033[0m" + "\r\033[K\033[92m» Found a BitChat device! Connecting...\033[0m" ) try: self.client = BleakClient( @@ -2866,7 +2859,7 @@ async def background_scanner(self): self.characteristic, self.notification_handler ) print( - f"\r\033[K\033[92m✓ Connected to BitChat network!\033[0m" + "\r\033[K\033[92m✓ Connected to BitChat network!\033[0m" ) # Clear any stale peers from previous connection @@ -2911,7 +2904,7 @@ async def background_scanner(self): ) await self.send_packet(identity_packet) except Exception as e: - debug_println(f"[SCANNER] Failed to send identity: {e}") + logger.info(f"[SCANNER] Failed to send identity: {e}") # Fallback key_exchange_payload = self.encryption_service.get_combined_public_key_data() key_exchange_packet = create_bitchat_packet( @@ -2932,7 +2925,7 @@ async def background_scanner(self): print("> ", end="", flush=True) except Exception as e: - debug_println(f"[SCANNER] Connection attempt failed: {e}") + logger.info(f"[SCANNER] Connection attempt failed: {e}") self.client = None self.characteristic = None @@ -2949,20 +2942,10 @@ async def input_loop(self): self.running = False break except Exception as e: - debug_println(f"[ERROR] Input error: {e}") + logger.info(f"[ERROR] Input error: {e}") async def run(self): """Main run loop""" - print_banner() - - # Parse command line arguments - global DEBUG_LEVEL - if "-dd" in sys.argv or "--debug-full" in sys.argv: - DEBUG_LEVEL = DebugLevel.FULL - print("🐛 Debug mode: FULL (verbose output)") - elif "-d" in sys.argv or "--debug" in sys.argv: - DEBUG_LEVEL = DebugLevel.BASIC - print("🐛 Debug mode: BASIC (connection info)") # Connect to BLE connected = await self.connect() @@ -2981,7 +2964,7 @@ async def run(self): except KeyboardInterrupt: pass finally: - debug_println("\n[+] Disconnecting...") + logger.info("\n[+] Disconnecting...") self.running = False # Send leave notification if connected @@ -2992,8 +2975,10 @@ async def run(self): ) await self.send_packet(leave_packet) await asyncio.sleep(0.1) # Give time for the packet to send - except: - pass # Ignore errors during shutdown + except Exception as e: # possible exceptions: (ValueError, EncryptionError, UnicodeDecodeError) + logger.info( + f"[ERROR] Error occurred during shutdown: {e}" + ) # Ignore errors during shutdown # Cancel background scanner if scanner_task: @@ -3010,28 +2995,111 @@ async def run(self): # Helper functions -def print_banner(): - """Print the BitChat banner""" - print( - "\n\033[38;5;46m##\\ ##\\ ##\\ ##\\ ##\\" +def nickname_is_valid(new_name: str) -> bool: + if not new_name: + print("\033[93m⚠ Usage: /name \033[0m") + print("\033[90mExample: /name Alice\033[0m") + elif len(new_name) > 20: + print("\033[93m⚠ Nickname too long\033[0m") + print("\033[90mMaximum 20 characters allowed.\033[0m") + elif not all(c.isalnum() or c in "-_" for c in new_name): + print("\033[93m⚠ Invalid nickname\033[0m") + print( + "\033[90mNicknames can only contain letters, numbers, hyphens and underscores.\033[0m" + ) + elif new_name in ["system", "all"]: + print("\033[93m⚠ Reserved nickname\033[0m") + print("\033[90mThis nickname is reserved and cannot be used.\033[0m") + else: + return True + return False + + +def use_cli_args() -> argparse.Namespace: + parser = argparse.ArgumentParser("bitchat-python") + parser.add_argument( + "-n", + "--name", + action="store", + type=str, + help="start client with specific nickname", + ) + parser.add_argument( + "-u", "--usage", action="store_true", help="show usage info on startup" + ) + parser.add_argument( + "-d", + "--debug", + action="store_true", + help="enable BASIC debug (connection info)", + ) + parser.add_argument( + "-v", # maybe better -v than -dd + "--verbose", # maybe better --verbose than --debug-full + action="store_true", + help="enable FULL debug (verbose output)", ) - print("## | \\__| ## | ## | ## |") - print("#######\\ ##\\ ######\\ #######\\ #######\\ ######\\ ######\\") - print("## __##\\ ## |\\_## _| ## _____|## __##\\ \\____##\\\\_## _|") - print("## | ## |## | ## | ## / ## | ## | ####### | ## |") - print("## | ## |## | ## |##\\ ## | ## | ## |## __## | ## |##\\") - print("####### |## | \\#### |\\#######\\ ## | ## |\\####### | \\#### |") - print( - "\\_______/ \\__| \\____/ \\_______|\\__| \\__| \\_______| \\____/\033[0m" + parser.add_argument( + "-V", + "--version", + action="version", + version=f"{parser.prog} {__version__}", ) - print( - "\n\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m" + parser.add_argument( + "--log", + action="store", + nargs="?", # Allows 0 or 1 argument + type=Path, + help="log file path. If no path is provided, logs to 'bitchat.log'. If --log is omitted, no logging occurs.", + const=Path("bitchat.log"), # Value if --log is present but no path is given + default=None, # Value if --log is not present at all ) - print("\033[37mDecentralized • Encrypted • Peer-to-Peer • Open Source\033[0m") - print(f"\033[37m bitchat@-python {VERSION} @kaganisildak\033[0m") - print( + args = parser.parse_args() + + # print banner only if args parsing success + print_banner() + + # setup logging + if args.log: + enable_file_logging(args.log) + + if args.verbose: + logger.setLevel(logging.DEBUG) + logger.info("🐛 Debug mode: FULL (verbose output)") + elif args.debug: + logger.setLevel(logging.INFO) + logger.info("🐛 Debug mode: BASIC (connection info)") + + # print usage + if args.usage: + print_usage() + return args + + +def get_banner() -> str: + """ + Returns the BitChat banner as a multi-line string literal. + """ + banner = ( + "\n\033[38;5;46m##\\ ##\\ ##\\ ##\\ ##\\\n" + "## | \\__| ## | ## | ## |\n" + "#######\\ ##\\ ######\\ #######\\ #######\\ ######\\ ######\\\n" + "## __##\\ ## |\\_## _| ## _____|## __##\\ \\____##\\\\_## _|\n" + "## | ## |## | ## | ## / ## | ## | ####### | ## |\n" + "## | ## |## | ## |##\\ ## | ## | ## |## __## | ## |##\\\n" + "####### |## | \\#### |\\#######\\ ## | ## |\\####### | \\#### |\n" + "\\_______/ \\__| \\____/ \\_______|\\__| \\__| \\_______| \\____/\033[0m\n" + "\n\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n" + "\033[37mDecentralized • Encrypted • Peer-to-Peer • Open Source\033[0m\n" + f"\033[37m bitchat@ the terminal {__version__}\033[0m\n" "\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m\n" ) + return banner + + +def print_banner() -> None: + """Print the BitChat banner""" + print(get_banner()) def unpad_packet(data: bytes) -> bytes: @@ -3121,7 +3189,7 @@ def parse_bitchat_packet(data: bytes) -> BitchatPacket: if len(data) >= offset + SIGNATURE_SIZE: signature = data[offset : offset + SIGNATURE_SIZE] else: - debug_println( + logger.info( f"[WARN] Packet has signature flag but not enough data for signature." ) @@ -3232,7 +3300,7 @@ def create_bitchat_packet_with_recipient( signature: Optional[bytes], ) -> bytes: """Create a BitChat packet with all options""" - debug_full_println( + logger.debug( f"[RAW SEND] Creating packet: type={msg_type.name}, payload_len={len(payload)}" ) @@ -3324,7 +3392,7 @@ def create_bitchat_packet_with_recipient( # Add hex logging to match iOS format final_packet = bytes(packet) hex_string = " ".join(f"{b:02X}" for b in final_packet) - debug_full_println(f"[RAW SEND] {hex_string}") + logger.debug(f"[RAW SEND] {hex_string}") return final_packet @@ -3389,9 +3457,7 @@ def create_bitchat_message_payload_full( data.append(len(channel_bytes)) data.extend(channel_bytes) - return (bytes(data), message_id) - - return (bytes(data), message_id) + return bytes(data), message_id def unpad_message(data: bytes) -> bytes: @@ -3447,7 +3513,11 @@ def should_send_ack( async def main(): """Main entry point""" + # Parse command line arguments + args = use_cli_args() client = BitchatClient() + if args.name and nickname_is_valid(args.name): + await client.update_name(args.name) await client.run() diff --git a/src/bitchat_python/compression.py b/src/bitchat_python/compression.py new file mode 100644 index 0000000..925488c --- /dev/null +++ b/src/bitchat_python/compression.py @@ -0,0 +1,67 @@ +from typing import Tuple + +try: + import lz4.frame # type: ignore[import-untyped,import-not-found] + + COMPRESSION_THRESHOLD = 100 + + def compress_if_beneficial(data: bytes) -> Tuple[bytes, bool]: + """Compress data if it reduces size""" + if len(data) < COMPRESSION_THRESHOLD: + return data, False + + compressed = lz4.frame.compress(data) + if len(compressed) < len(data): + return compressed, True + else: + return data, False + + def decompress(data: bytes) -> bytes: + """Decompress LZ4 data""" + try: + return lz4.frame.decompress(data) + except Exception as e: + raise ValueError(f"Decompression failed: {e}") + +except ImportError: + # fallback to zlib + import zlib + + def compress_if_beneficial(data: bytes) -> Tuple[bytes, bool]: + """ + Compress data using zlib if it reduces size. + Suitable for pure Python and many MicroPython environments. + """ + if len(data) < COMPRESSION_THRESHOLD: + return data, False + + # Using zlib.compressobj for more control, especially useful if + # you want to stream data or handle larger chunks. + # The default compression level is -1, which is a good balance. + # Level 9 is best compression, 1 is fastest. Let's use 1 for speed + # which is often preferred in embedded contexts if compression is needed. + compressor = zlib.compressobj(level=1) + compressed = compressor.compress(data) + compressed += compressor.flush() # Don't forget to flush remaining data + + if len(compressed) < len(data): + return compressed, True + else: + return data, False + + def decompress(data: bytes) -> bytes: + """ + Decompress zlib data. + Suitable for pure Python and many MicroPython environments. + """ + try: + # zlib.decompress handles the entire compressed data block + return zlib.decompress(data) + except zlib.error as e: # Catch zlib's specific error for better clarity + raise ValueError(f"Decompression failed: {e}") + except Exception as e: # Catch other potential exceptions + raise ValueError(f"Decompression failed with unexpected error: {e}") + + +# Export functions +__all__ = ("compress_if_beneficial", "decompress", "COMPRESSION_THRESHOLD") diff --git a/encryption.py b/src/bitchat_python/encryption.py similarity index 99% rename from encryption.py rename to src/bitchat_python/encryption.py index 4839de5..0f4f9bc 100644 --- a/encryption.py +++ b/src/bitchat_python/encryption.py @@ -4,21 +4,19 @@ Compatible with Swift NoiseEncryptionService implementation. """ +import hashlib import os import time -import json -import secrets from dataclasses import dataclass from typing import Optional, Dict, Tuple, Callable + from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey, ) from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 -from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.hmac import HMAC -import hashlib # Noise Protocol Constants NOISE_PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_SHA256" @@ -680,7 +678,7 @@ def process_handshake_message( del self.handshake_states[peer_id] # print(f"[NOISE] Handshake failed with {peer_id}: {type(e).__name__}: {e}") # print(f"[NOISE] Message length: {len(message)}, first 32 bytes: {message[:32].hex()}") - import traceback + # import traceback # FIXME: unused import # print(f"[NOISE] Handshake error details: {traceback.format_exc()}") # print(f"[NOISE] Original exception type: {type(e).__name__}") diff --git a/fragmentation.py b/src/bitchat_python/fragmentation.py similarity index 94% rename from fragmentation.py rename to src/bitchat_python/fragmentation.py index 9533cde..5c69fd9 100644 --- a/fragmentation.py +++ b/src/bitchat_python/fragmentation.py @@ -1,6 +1,6 @@ import os -from enum import IntEnum from dataclasses import dataclass +from enum import IntEnum from typing import List MAX_FRAGMENT_SIZE = 500 @@ -58,4 +58,4 @@ def fragment_payload(payload: bytes, original_msg_type: int) -> List[Fragment]: # Export classes and functions -__all__ = ["Fragment", "FragmentType", "fragment_payload", "MAX_FRAGMENT_SIZE"] +__all__ = ("Fragment", "FragmentType", "fragment_payload", "MAX_FRAGMENT_SIZE") diff --git a/persistence.py b/src/bitchat_python/persistence.py similarity index 92% rename from persistence.py rename to src/bitchat_python/persistence.py index faec915..a7efdaa 100644 --- a/persistence.py +++ b/src/bitchat_python/persistence.py @@ -1,14 +1,15 @@ -import os -import json import hashlib +import json +import os +from dataclasses import dataclass, field from pathlib import Path from typing import Dict, Set, List, Optional -from dataclasses import dataclass, field, asdict -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.backends import default_backend + +from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from bitchat_python._logger import logger @dataclass @@ -55,8 +56,8 @@ def load_state() -> AppState: if path.exists(): try: - with open(path, "r") as f: - data = json.load(f) + with open(path, "r") as fp: + data = json.load(fp) # Convert lists back to sets if "blocked_peers" in data: @@ -81,7 +82,7 @@ def load_state() -> AppState: state = AppState(**data) except Exception as e: - print(f"Warning: Could not parse state file: {e}") + logger.warn(f"Warning: Could not parse state file: {e}") state = AppState() else: state = AppState() @@ -121,8 +122,8 @@ def save_state(state: AppState) -> None: }, } - with open(path, "w") as f: - json.dump(data, f, indent=2) + with open(path, "w") as fp: + json.dump(data, fp, indent=2) def derive_encryption_key(identity_key: bytes) -> bytes: diff --git a/src/bitchat_python/py.typed b/src/bitchat_python/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/terminal_ux.py b/src/bitchat_python/terminal_ux.py similarity index 52% rename from terminal_ux.py rename to src/bitchat_python/terminal_ux.py index 7165309..8692e07 100644 --- a/terminal_ux.py +++ b/src/bitchat_python/terminal_ux.py @@ -1,51 +1,59 @@ +from dataclasses import dataclass, field from datetime import datetime +from enum import Enum, auto from typing import Dict, List, Optional, Tuple -from dataclasses import dataclass +DATETIME_FORMAT = "%H:%M:%S" -@dataclass -class ChatMode: - """Base class for chat modes""" - - pass +class ChatMode(Enum): + """Chat modes enumeration""" -@dataclass -class Public(ChatMode): - """Public chat mode""" - - pass + Public = auto() + Channel = auto() + PrivateDM = auto() @dataclass -class Channel(ChatMode): - """Channel chat mode""" +class Chat: + """Keeps chat mode, user or channel name, and peer_id""" - name: str + mode: ChatMode = field(default=ChatMode.Public) # chat mode aka type + name: Optional[str] = field( + default=None + ) # unified attribute for nickname/channel_name + peer_id: Optional[str] = field(default=None) + @classmethod + def pub(cls) -> "Chat": + """Factory method to create public chat instance""" + return cls(ChatMode.Public) -@dataclass -class PrivateDM(ChatMode): - """Private DM mode""" + @classmethod + def chan(cls, channame: str) -> "Chat": + """Factory method to create channel chat instance""" + return cls(ChatMode.Channel, name=channame) - nickname: str - peer_id: str + @classmethod + def private(cls, nickname: str, peer_id: str) -> "Chat": + """Factory method to create private chat instance""" + return cls(ChatMode.PrivateDM, name=nickname, peer_id=peer_id) +@dataclass class ChatContext: - def __init__(self): - self.current_mode: ChatMode = Public() - self.active_channels: List[str] = [] - self.active_dms: Dict[str, str] = {} # nickname -> peer_id - self.last_private_sender: Optional[Tuple[str, str]] = None + current_chat: Chat = field(default_factory=Chat.pub) + active_channels: List[str] = field(default_factory=list) + active_dms: Dict[str, str] = field(default_factory=dict) # nickname -> peer_id + last_private_sender: Optional[Tuple[str, str]] = None def format_prompt(self) -> str: - if isinstance(self.current_mode, Public): + if self.current_chat.mode is ChatMode.Public: return "[Public]" - elif isinstance(self.current_mode, Channel): - return f"[{self.current_mode.name}]" - elif isinstance(self.current_mode, PrivateDM): - return f"[DM: {self.current_mode.nickname}]" + elif self.current_chat.mode is ChatMode.Channel: + return f"[{self.current_chat.name}]" + elif self.current_chat.mode is ChatMode.PrivateDM: + return f"[DM: {self.current_chat.name}]" return ">" def get_status_line(self) -> str: @@ -62,7 +70,7 @@ def get_status_line(self) -> str: def switch_to_number(self, num: int) -> bool: if num == 1: - self.current_mode = Public() + self.current_chat = Chat.pub() print("\033[90m─────────────────────────\033[0m") print( "\033[90m» Switched to Public chat. Just type to send messages.\033[0m" @@ -74,7 +82,7 @@ def switch_to_number(self, num: int) -> bool: channel_idx = num - 2 if channel_idx < len(self.active_channels): channel = self.active_channels[channel_idx] - self.current_mode = Channel(channel) + self.current_chat = Chat.chan(channel) print("\033[90m─────────────────────────\033[0m") print(f"\033[90m» Switched to channel {channel}\033[0m") return True @@ -84,7 +92,7 @@ def switch_to_number(self, num: int) -> bool: dm_list = list(self.active_dms.items()) if dm_idx < len(dm_list): nick, peer_id = dm_list[dm_idx] - self.current_mode = PrivateDM(nick, peer_id) + self.current_chat = Chat.private(nick, peer_id) print("\033[90m─────────────────────────\033[0m") print( f"\033[90m» Switched to DM with {nick}. Just type to send messages.\033[0m" @@ -93,54 +101,54 @@ def switch_to_number(self, num: int) -> bool: return False - def add_channel(self, channel: str): + def add_channel(self, channel: str) -> None: if channel not in self.active_channels: self.active_channels.append(channel) - def add_dm(self, nickname: str, peer_id: str): + def add_dm(self, nickname: str, peer_id: str) -> None: self.active_dms[nickname] = peer_id - def enter_dm_mode(self, nickname: str, peer_id: str): + def enter_dm_mode(self, nickname: str, peer_id: str) -> None: self.add_dm(nickname, peer_id) - self.current_mode = PrivateDM(nickname, peer_id) + self.current_chat = Chat.private(nickname, peer_id) print("\033[90m─────────────────────────\033[0m") print( f"\033[90m» Entered DM mode with {nickname}. Just type to send messages.\033[0m" ) - def switch_to_channel(self, channel: str): + def switch_to_channel(self, channel: str) -> None: self.add_channel(channel) - self.current_mode = Channel(channel) + self.current_chat = Chat.chan(channel) print("\033[90m─────────────────────────\033[0m") print(f"\033[90m» Switched to channel {channel}\033[0m") - def switch_to_channel_silent(self, channel: str): + def switch_to_channel_silent(self, channel: str) -> None: self.add_channel(channel) - self.current_mode = Channel(channel) + self.current_chat = Chat.chan(channel) - def switch_to_public(self): - self.current_mode = Public() + def switch_to_public(self) -> None: + self.current_chat = Chat.pub() print("\033[90m─────────────────────────\033[0m") print("\033[90m» Switched to Public chat. Just type to send messages.\033[0m") - def remove_channel(self, channel: str): + def remove_channel(self, channel: str) -> None: if channel in self.active_channels: self.active_channels.remove(channel) - def show_conversation_list(self): + def show_conversation_list(self) -> None: print("\n╭─── Active Conversations ───╮") print("│ │") # Public - indicator = "→" if isinstance(self.current_mode, Public) else " " - print(f"│ {indicator} [1] Public │") + indicator = "→" if self.current_chat.mode is ChatMode.Public else " " + print(f"│ {indicator} [1] Public │") # Channels num = 2 for channel in self.active_channels: is_current = ( - isinstance(self.current_mode, Channel) - and self.current_mode.name == channel + self.current_chat is ChatMode.Channel + and self.current_chat.name == channel ) indicator = "→" if is_current else " " padding = " " * (18 - len(channel)) @@ -150,8 +158,8 @@ def show_conversation_list(self): # DMs for nick, _ in self.active_dms.items(): is_current = ( - isinstance(self.current_mode, PrivateDM) - and self.current_mode.nickname == nick + self.current_chat.mode is ChatMode.PrivateDM + and self.current_chat.name == nick ) indicator = "→" if is_current else " " dm_text = f"DM: {nick}" @@ -197,109 +205,118 @@ def format_message_display( my_nickname: str, ) -> str: """Format a message for display""" - time_str = timestamp.strftime("%H:%M") + time_str = timestamp.strftime(DATETIME_FORMAT) if is_private: # Orange for private messages (matching iOS) if sender == my_nickname: # Message I sent - use brighter orange if recipient: - return f"\033[2;38;5;208m[{time_str}|DM]\033[0m \033[38;5;214m\033[0m {content}" + return f"\033[2;38;5;208m[{time_str}|DM]\033[0m \033[38;5;214m\033[0m {content}" else: return f"\033[2;38;5;208m[{time_str}|DM]\033[0m \033[38;5;214m\033[0m {content}" else: # Message I received - use normal orange - return f"\033[2;38;5;208m[{time_str}|DM]\033[0m \033[38;5;208m<{sender} → you>\033[0m {content}" + return f"\033[2;38;5;208m[{time_str}|DM]\033[0m \033[38;5;208m<@{sender} → you>\033[0m {content}" elif is_channel: # Blue for channel messages (matching iOS) if sender == my_nickname: # My messages - light blue (256-color) if channel_name: - return f"\033[2;34m[{time_str}|{channel_name}]\033[0m \033[38;5;117m<{sender} @ {channel_name}>\033[0m {content}" + return f"\033[2;34m[{time_str}|{channel_name}]\033[0m \033[38;5;117m<@{sender} @ {channel_name}>\033[0m {content}" else: - return f"\033[2;34m[{time_str}|Ch]\033[0m \033[38;5;117m<{sender} @ ???>\033[0m {content}" + return f"\033[2;34m[{time_str}|Ch]\033[0m \033[38;5;117m<@{sender} @ ???>\033[0m {content}" else: # Other users - normal blue if channel_name: - return f"\033[2;34m[{time_str}|{channel_name}]\033[0m \033[34m<{sender} @ {channel_name}>\033[0m {content}" + return f"\033[2;34m[{time_str}|{channel_name}]\033[0m \033[34m<@{sender} @ {channel_name}>\033[0m {content}" else: - return f"\033[2;34m[{time_str}|Ch]\033[0m \033[34m<{sender} @ ???>\033[0m {content}" + return f"\033[2;34m[{time_str}|Ch]\033[0m \033[34m<@{sender} @ ???>\033[0m {content}" else: # Public message - green for metadata if sender == my_nickname: # My messages - light green (256-color) - return f"\033[2;32m[{time_str}]\033[0m \033[38;5;120m<{sender}>\033[0m {content}" + return f"\033[2;32m[{time_str}]\033[0m \033[38;5;120m<@{sender}>\033[0m {content}" else: # Other users - normal green - return f"\033[2;32m[{time_str}]\033[0m \033[32m<{sender}>\033[0m {content}" - - -def print_help(): - """Print help menu""" - print("\n\033[38;5;46m━━━ BitChat Commands ━━━\033[0m\n") - - # General - print("\033[38;5;40m▶ General\033[0m") - print(" \033[36m/help\033[0m Show this help menu") - print(" \033[36m/name\033[0m \033[90m\033[0m Change your nickname") - print(" \033[36m/status\033[0m Show connection info") - print(" \033[36m/clear\033[0m Clear the screen") - print(" \033[36m/exit\033[0m Quit BitChat\n") - - # Navigation - print("\033[38;5;40m▶ Navigation\033[0m") - print(" \033[36m1-9\033[0m Quick switch to conversation") - print(" \033[36m/list\033[0m Show all conversations") - print(" \033[36m/switch\033[0m Interactive conversation switcher") - print(" \033[36m/public\033[0m Go to public chat\n") - - # Messaging - print("\033[38;5;40m▶ Messaging\033[0m") - print(" \033[90m(type normally to send in current mode)\033[0m") - print(" \033[36m/dm\033[0m \033[90m\033[0m Start private conversation") - print(" \033[36m/dm\033[0m \033[90m \033[0m Send quick private message") - print(" \033[36m/reply\033[0m Reply to last private message\n") - - # Channels - print("\033[38;5;40m▶ Channels\033[0m") - print(" \033[36m/j\033[0m \033[90m#channel\033[0m Join or create a channel") - print(" \033[36m/j\033[0m \033[90m#channel \033[0m Join with password") - print(" \033[36m/leave\033[0m Leave current channel") - print( - " \033[36m/pass\033[0m \033[90m\033[0m Set channel password (owner only)" - ) - print( + return f"\033[2;32m[{time_str}]\033[0m \033[32m<@{sender}>\033[0m {content}" + + +def get_usage() -> str: + """ + Returns the BitChat help menu as a multi-line string literal. + """ + usage_menu = ( + "\n\033[38;5;46m━━━ BitChat Commands ━━━\033[0m\n" + "\n" + # General + "\033[38;5;40m▶ General\033[0m\n" + " \033[36m/help\033[0m Show this help menu\n" + " \033[36m/h\033[0m Alias for /help\n" + " \033[36m/me\033[0m Get your Nickname and ID\n" + " \033[36m/name\033[0m \033[90m\033[0m Change your nickname\n" + " \033[36m/status\033[0m Show connection info\n" + " \033[36m/clear\033[0m Clear the screen\n" + " \033[36m/exit\033[0m Quit BitChat\n" + " \033[36m/q\033[0m Alias for /exit\n" + "\n" + # Navigation + "\033[38;5;40m▶ Navigation\033[0m\n" + " \033[36m1-9\033[0m Quick switch to conversation\n" + " \033[36m/list\033[0m Show all conversations\n" + " \033[36m/switch\033[0m Interactive conversation switcher\n" + " \033[36m/public\033[0m Go to public chat\n" + "\n" + # Messaging + "\033[38;5;40m▶ Messaging\033[0m\n" + " \033[90m(type normally to send in current mode)\033[0m\n" + " \033[36m/dm\033[0m \033[90m\033[0m Start private conversation\n" + " \033[36m/dm\033[0m \033[90m \033[0m Send quick private message\n" + " \033[36m/reply\033[0m Reply to last private message\n" + "\n" + # Channels + "\033[38;5;40m▶ Channels\033[0m\n" + " \033[36m/j\033[0m \033[90m#channel\033[0m Join or create a channel\n" + " \033[36m/j\033[0m \033[90m#channel \033[0m Join with password\n" + " \033[36m/leave\033[0m Leave current channel\n" + " \033[36m/pass\033[0m \033[90m\033[0m Set channel password (owner only)\n" " \033[36m/transfer\033[0m \033[90m@user\033[0m Transfer ownership (owner only)\n" + "\n" + # Discovery + "\033[38;5;40m▶ Discovery\033[0m\n" + " \033[36m/channels\033[0m List all discovered channels\n" + " \033[36m/online\033[0m Show who's online\n" + " \033[36m/w\033[0m Alias for /online\n" + "\n" + # Privacy & Security + "\033[38;5;40m▶ Privacy & Security\033[0m\n" + " \033[36m/block\033[0m \033[90m@user\033[0m Block a user\n" + " \033[36m/block\033[0m List blocked users\n" + " \033[36m/unblock\033[0m \033[90m@user\033[0m Unblock a user\n" + "\n" + "\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m" ) + return usage_menu - # Discovery - print("\033[38;5;40m▶ Discovery\033[0m") - print(" \033[36m/channels\033[0m List all discovered channels") - print(" \033[36m/online\033[0m Show who's online") - print(" \033[36m/w\033[0m Alias for /online\n") - - # Privacy & Security - print("\033[38;5;40m▶ Privacy & Security\033[0m") - print(" \033[36m/block\033[0m \033[90m@user\033[0m Block a user") - print(" \033[36m/block\033[0m List blocked users") - print(" \033[36m/unblock\033[0m \033[90m@user\033[0m Unblock a user\n") - print("\033[38;5;40m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m") +def print_usage() -> None: + """Print help menu""" + print(get_usage()) -def clear_screen(): +def clear_screen() -> None: """Clear the terminal screen""" print("\033[2J\033[1;1H", end="") # Export classes -__all__ = [ +__all__ = ( + "DATETIME_FORMAT", "ChatMode", - "Public", - "Channel", - "PrivateDM", + "Chat", "ChatContext", "format_message_display", - "print_help", + "get_usage", + "print_usage", "clear_screen", -] +) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..03a74da --- /dev/null +++ b/uv.lock @@ -0,0 +1,913 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" + +[[package]] +name = "aioconsole" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/c9/c57e979eea211b10a63783882a826f257713fa7c0d6c9a6eac851e674fb4/aioconsole-0.8.1.tar.gz", hash = "sha256:0535ce743ba468fb21a1ba43c9563032c779534d4ecd923a46dbd350ad91d234", size = 61085 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/ea/23e756ec1fea0c685149304dda954b3b3932d6d06afbf42a66a2e6dc2184/aioconsole-0.8.1-py3-none-any.whl", hash = "sha256:e1023685cde35dde909fbf00631ffb2ed1c67fe0b7058ebb0892afbde5f213e5", size = 43324 }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, +] + +[[package]] +name = "bitarray" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/e9/be1722981d43341ec1da6370255c414ec00ba23a99e01fc315dbe4c5c9f4/bitarray-3.5.1.tar.gz", hash = "sha256:b03c49d1a2eb753cc6090053f1c675ada71e1c3ea02011f1996cf4c2b6e9d6d6", size = 148859 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/3c/0e4fd841dbf38792845e080c007f0aa8fd255d7dd14d8026427fb4de3284/bitarray-3.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0af817f8440fd382414773fe75da0d6cac69d3e784bec106bae540b9de9b020", size = 145269 }, + { url = "https://files.pythonhosted.org/packages/34/f2/bfd3cff3257171343d30535d3c9f078ed8a8b9154aa6a6fb4088f3b1a92d/bitarray-3.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1a383e25820643dcfaa5647e430ac990007f0163c525f98fc90e88d0a332662", size = 141891 }, + { url = "https://files.pythonhosted.org/packages/64/a6/b360e8baf4b61e3152c5dc93622a358ed0b8b53ddbc6bd1223686f265ac6/bitarray-3.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:202d769109fc81066eed80e936c1c1e2864547e81830c8578a4b90e530118c15", size = 311200 }, + { url = "https://files.pythonhosted.org/packages/4d/a2/aaca748379ad9c2f8832a05fed4a0f3edcb278ed2a7875e6fa9cbf8dbaf1/bitarray-3.5.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9e5be9949ee1cf60382d976f25980d61421387655c28be1571b607bd33578bb", size = 327132 }, + { url = "https://files.pythonhosted.org/packages/0b/2b/18b3d810f5d428d1df572a3a563e62daa015dbb512dd8e9a3c0c1fe0e588/bitarray-3.5.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9450d6579da0d8d087694623307cfbd6cfd13a49c221ead5ff1975309956ae28", size = 319427 }, + { url = "https://files.pythonhosted.org/packages/a3/c4/fa33dda834e75cce7d36fec0f8609aaba4ee3c43b9bb6bd33cb84563f459/bitarray-3.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11c79c1edd86ac4fc68bd14947d8f57746afbc326288930e8202d93906443af", size = 312728 }, + { url = "https://files.pythonhosted.org/packages/48/cb/4535a9bcffc9265c8bc2fd7285b419e8ca68c121cf4bec66c160dc87075e/bitarray-3.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e789bfa751deec734fc7f9fabc82f03fc680fa26196a417b69a101a3d672dfa", size = 300288 }, + { url = "https://files.pythonhosted.org/packages/f1/78/e8e69bbd45f1d4a27b770bd77e1956171f9bdbdecbcf19c8439706f2d9d9/bitarray-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6c7af1e0b02d54875060b15db85f9634bcd2f45d306508dbf8ceeb157f64fdac", size = 305351 }, + { url = "https://files.pythonhosted.org/packages/7c/c0/31750c88321367718c4fbf63a68fe62c79d0511807add583176934a9926e/bitarray-3.5.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1d82bdf87186adfb23336c7f2f6c4c54c1e3f26c1dc6ec0b5fbbb6cba3a46266", size = 296893 }, + { url = "https://files.pythonhosted.org/packages/bf/f6/5f1ff2569effba20d65423e38c2390d00ab305bd59d2d749ddda0bae0811/bitarray-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:65cbee9dfc85ad88ceea21c669b48f5b90dd40181c12e5fc097cf9863d04bfb1", size = 322137 }, + { url = "https://files.pythonhosted.org/packages/0d/06/1d7465046828dcc637378f23b3dbb2bed35ce22a0022da2f3b568d8f97d7/bitarray-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6167f48ff31f8153090c2f75f60f6032adb7d2d3c0dfc61eb96f6e654f66c860", size = 324659 }, + { url = "https://files.pythonhosted.org/packages/cf/fe/18afc129abcc870bb6d9f7bbe184b9782736d5759c023306f75a828b11cc/bitarray-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b230d1bd0b0fd4264165220a267479f97e18bb856734375e678ff77d3d63e25f", size = 304264 }, + { url = "https://files.pythonhosted.org/packages/35/82/795fe160473ddb816781cc15eed1e60c91157c786c9fa07320f700ef97e3/bitarray-3.5.1-cp310-cp310-win32.whl", hash = "sha256:224739107aa0e7590a5166c52866a2c69412546469f7a71bfb5b89d38cb7d0d5", size = 138740 }, + { url = "https://files.pythonhosted.org/packages/d6/00/8858efb2b6c489e864b9650f03fae9391e9a25614c7941e08604c24f48c7/bitarray-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:f404a0f6f37ade9aba1ca18d0f0ca76c729bb36a7ddd42f62fb899f6b99e3b4b", size = 145323 }, + { url = "https://files.pythonhosted.org/packages/fb/c5/3559e72a879452d5bb6bd78315b6eff0add2aad8a04e9a82a44d6240c8f3/bitarray-3.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26db9776442fc60a7883c55624cdf1a0446814e9516622c81c32fe84a83596d0", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/b0/19/1e1eaf0739d302cb50313080adfc548f4fdcdb421a1ac8102096e375e601/bitarray-3.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97e89468fe8dae7cabddd3e49487fc977e191734708cdf720d0887a53a1601fb", size = 141893 }, + { url = "https://files.pythonhosted.org/packages/02/91/6d6aa58ca745c70b5655f5d7188c2e195c5267312becaf8c9dd4b7c215dc/bitarray-3.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e412a62386d64926ab411bc882a6847dc187742a0a01da624a9aabe378ce2f5", size = 318852 }, + { url = "https://files.pythonhosted.org/packages/cc/d0/659e1c9b092cdee915ecefacdb5450975cd965c002229daf1e091209e1dd/bitarray-3.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6ce16b3c7bec57686e6cb00b0a6d7b5357fa37cf2f67fa8221b774876512800", size = 335179 }, + { url = "https://files.pythonhosted.org/packages/f8/fb/c7860b11304fc6f5971ec9fe5a56d2f00ab67b4fd7749f5cd0887fff8b78/bitarray-3.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00ca03adfdfc0999c5b715ccebf26a7c396b79ccd9c7c00e78e6a44cca5405a0", size = 328153 }, + { url = "https://files.pythonhosted.org/packages/5c/eb/0accf6da6dcc80a968c1ab53afc9235242283fbc853248bdf073c0e463f0/bitarray-3.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76c34e1c61888a36dc7ae1cc9b9b7c901a28900779697666a280191fdd06986a", size = 320546 }, + { url = "https://files.pythonhosted.org/packages/1a/07/64c868bd8399b7ad118fe94b39b70c8f68fd3c1003aca8f637bcc18baa0c/bitarray-3.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df1d12414c8e1f52995b1079755b49d1902c333789a12e550d28e0ab991bdaaa", size = 308421 }, + { url = "https://files.pythonhosted.org/packages/c6/f5/042f16d77d13ddd3d3dedaf3913454b718287f889fa6ff26b45a9d6bc7ab/bitarray-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b1e573f6b8efb41716411a00ea2dedc69f22305a46b107c764b2b50a545c2b53", size = 313451 }, + { url = "https://files.pythonhosted.org/packages/cf/9b/7ea1caa21640f85c1d102c1b2a325c5705cff30cbd748b2f7c8e61bab988/bitarray-3.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3cb3cbaa3ae3894888c6c3f21f6586b0733de6a53ea38a67a27a89a49703d9c1", size = 304573 }, + { url = "https://files.pythonhosted.org/packages/de/62/03e234169dab7b02bd4be80a55aafc216713b5732f4c408b5ebc4d255a91/bitarray-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fd9d24c033ccf4776ecf3c31288e98a5656f8f63a944508a7a6afd2f41fd57eb", size = 329973 }, + { url = "https://files.pythonhosted.org/packages/6d/fb/bdaf7653c8e9bef38b4315779d98c436fa08d9408d9c71347c424ec02dc5/bitarray-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79ff7a768da4d9e99a07208f318378ef63c06673712371f2433fd54ea3f41772", size = 332840 }, + { url = "https://files.pythonhosted.org/packages/34/c7/f1d94b19ce623d63b3cb86045843e57da49bf1a18e793112c1743cc0c625/bitarray-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:792c9851472ae0fbe870b0c06c413ab2ab8d495b53c146bc7828be8d716f7a1e", size = 312417 }, + { url = "https://files.pythonhosted.org/packages/f6/5f/25715f4080f4a9758ed59c0e3b03a9ea04db04323ed01bf96a47939c85de/bitarray-3.5.1-cp311-cp311-win32.whl", hash = "sha256:2a5a5ca31fee5871387d636d50fdef6f5d232170d539dc19022add2eedf363a5", size = 138915 }, + { url = "https://files.pythonhosted.org/packages/e5/d2/a7aefc2e5e0144a9981b35eb335f5dbffc575ad4ed60167752e503504e9a/bitarray-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f14f1e64784260d01503553b39b8731d5f8e0e101b6fcc72e5558d07d6ad39d", size = 145542 }, + { url = "https://files.pythonhosted.org/packages/d6/d1/510f11b59b5d3def3ff6d80534246f3676c71f521b222a54865313be5771/bitarray-3.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6cbe639f19993fa73eb6464b6dfd5bd9f19951b56eca7fcafc6ca59c9686fea4", size = 144993 }, + { url = "https://files.pythonhosted.org/packages/10/75/e78381421de90ffd5a2f33f6611c72f88f86f600f97a878441fbf35d130c/bitarray-3.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37d00d0dc4d5df12ac1a2f7c7f954b23a9cfa43daf88a2a89d871668ed495cd8", size = 141873 }, + { url = "https://files.pythonhosted.org/packages/1f/3a/61e792211051e52863e7cb2097bdc05d0af0ea6aba9a2858f9726595688d/bitarray-3.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5350ecdf7138fb62298d893044023792925031188dcb8127406c670bb07736e", size = 321578 }, + { url = "https://files.pythonhosted.org/packages/9e/4b/36a007d864c366bf6640e7ae55e48feddcbb0bd1f01ec2b579d9ef23ebce/bitarray-3.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1699973ca9c9056785261a0cad6f83b8ad61f0e410b1ca9969c81a34d241a5c2", size = 337140 }, + { url = "https://files.pythonhosted.org/packages/8b/a7/6dc760f4e8d070c658eb1f0fbc981d95994594ec8c380ec3ec482afd4b71/bitarray-3.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280e34073ce57afe8f530e99e70e3b929843ad43138f87aeb7dc3090630c0eb2", size = 330751 }, + { url = "https://files.pythonhosted.org/packages/39/d3/e71f1531ee07ce1e47c2c61bdc87fe78c27ef0059d7f055d10488bf9b579/bitarray-3.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b04b802fa9e1d8b0ed2d2448430ff0ce6a3232e69b48b62962507297af65853a", size = 323649 }, + { url = "https://files.pythonhosted.org/packages/a9/a4/60d4d15e86b7232f8cc538b98e89cd1a10f285a173eefdf86330c3f287fb/bitarray-3.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8fa5f1836cff442660632c65d73caa572ce10124cecdf366b64a74541e660b4e", size = 311011 }, + { url = "https://files.pythonhosted.org/packages/7e/9a/2b6c66b09b3dd45c0075935e0a1d52344ccffbc6d48a33ff219978fa1411/bitarray-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:efc78327e34e062797a6b6645d9d861e0aaeec4cd8da4d7221ce8c72df20e0bd", size = 315687 }, + { url = "https://files.pythonhosted.org/packages/c2/f0/6b638b889889c4ba96d4f255d21a29349627a0a5d2e2852b419913425c6e/bitarray-3.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45c959c8739a9cb7078d03295d7e6be8e89a4300dda9ccb4063cced6852748d5", size = 307433 }, + { url = "https://files.pythonhosted.org/packages/33/9a/0a93a936911836611f881f805876d0231c499202e8c8121b56e30c4ef4c0/bitarray-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd4ecba34031cb8d463f67dfb9537326758a0a32d8b62057d5a48eba8d015357", size = 331941 }, + { url = "https://files.pythonhosted.org/packages/34/d7/6528c843a5abb8bc38cfa82f76ddab5a6cd136a06cbc0ec2342f2eba7723/bitarray-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:edd138938be91cc3e99387529b59c93683e1c8a96ad38c4b920be7a29a80d076", size = 335596 }, + { url = "https://files.pythonhosted.org/packages/7d/3d/53dfe0258739f0ede9f0506a111b30373ec8f3b7f0ec126e4fc7cf498201/bitarray-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bcb10270a33deb2fd6ed10a922536db5323444c51f0d961a7eac39af23f7761d", size = 315566 }, + { url = "https://files.pythonhosted.org/packages/a6/35/655cdf157d86a1b0032f6a7d6d33dac50516d1cd1585c0c383a1a399625d/bitarray-3.5.1-cp312-cp312-win32.whl", hash = "sha256:fb0f196f70e8a1c0546b6f877e0f495701aa7168073f9f6beb3cea7b7d439981", size = 138950 }, + { url = "https://files.pythonhosted.org/packages/bb/37/7cd6b9685d68c55e43cf25a90c2437edbdefc7a40e98c38556c83bac4925/bitarray-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:c43d4ebf2d9e82fbb191f55979dabbb99949e41b9c5db5557a8cd0753863a7ae", size = 145757 }, + { url = "https://files.pythonhosted.org/packages/0a/a8/8fd41a4da5a3c36fbd3119626c6b06e2f735330d3dee1fe05dc861166966/bitarray-3.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0e412eda251690b0a187ce9abaadf05a4a8481efd51efd3dffc594c6070ad", size = 144976 }, + { url = "https://files.pythonhosted.org/packages/ec/c4/b7cf035661ef40015320fbcd7c3a1e03c1467a9d83ec68441f708b00994d/bitarray-3.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:609ec6c1139ff75c9abcaab88e9d9351412aaeebe15f66db44e00c58aa08bee5", size = 141870 }, + { url = "https://files.pythonhosted.org/packages/37/24/321644a8bbe55a96abc61c24dbc6622acec7e78f58cf76a26da6cbc21b82/bitarray-3.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b052f546b7519c5974ed733ef4895e56eed67e2cae6c48b0d87d6aa096c1265e", size = 321510 }, + { url = "https://files.pythonhosted.org/packages/3b/80/09491433107e98c91cb73262c3a18e88f8e573070f885428b96acb339475/bitarray-3.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2753cd31b382dbddba8acac1e5bdcfe9d44e404c583889bd268816b5d241d68e", size = 337023 }, + { url = "https://files.pythonhosted.org/packages/80/5a/21b9416d9af68ca5d85688a0024bf4cc6b88aedd570afa1829b5f52d52ce/bitarray-3.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c47b1bb5c2c6b2720ae1148862d5d8c74d10b1104cb62ba5085f1baf9eaf5cc4", size = 330604 }, + { url = "https://files.pythonhosted.org/packages/ee/80/e121335da48ee927a0a4b97ff0ab7175e1f59721640e5116f77f2185c449/bitarray-3.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af501232f8a914535e66b1d49f59c736792345fb47fe11d21009fcd0927d34da", size = 323539 }, + { url = "https://files.pythonhosted.org/packages/23/28/9541b4e8c52c8eb3bec3fbeda69d2dca1b507fbdd4561424af94e1866bda/bitarray-3.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6c976df1fce6c60c4bd403df2e477da5b038770f10ad5d38135e166f9052c7", size = 310866 }, + { url = "https://files.pythonhosted.org/packages/85/9a/5dadbd3a76119d5c021653517e07d56a75112282edda3a701f8082af0de7/bitarray-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fa357072eb27b573aa6b4ff48e963261e1bf27e50fc50dbe232fed3273600a39", size = 315694 }, + { url = "https://files.pythonhosted.org/packages/58/18/66e16adb6f7b03b715a5d1ccd5dbb74ff364ddd65702a1661894572e38e1/bitarray-3.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d2196f20ccb878e23233c76a47fe7ef523b96361be3e257a5b10105a1f818289", size = 307452 }, + { url = "https://files.pythonhosted.org/packages/0c/db/97eed08e63ab752bdfa326b89e987e05b383a1c5d7aee5b6a3c748625108/bitarray-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8c18212e1cadc38003438907062e24742cc0979134923070b54042a71ead06a0", size = 331943 }, + { url = "https://files.pythonhosted.org/packages/bd/94/ed5c358156595adb37967dc85ef565774905ffdd8ebe383e2741f0ac564d/bitarray-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6834bf89530ad4cd8d80af9e89c7ae47ee376ff96b829fed6d818e417aab4b8a", size = 335566 }, + { url = "https://files.pythonhosted.org/packages/1b/93/174735954c25b1ea93e52ee5646ea8ac4cc06f38ae0a707dfe28af5ac868/bitarray-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab3cd324e4643d43b7227af5eb332ef28dbc123e68f36557ca1e8631bf292012", size = 315593 }, + { url = "https://files.pythonhosted.org/packages/49/3f/2a39a67604f06988d437f9830f44a434b43794a5269d1f1ee34c645d8e7d/bitarray-3.5.1-cp313-cp313-win32.whl", hash = "sha256:09fed8c5e4584f498c2e1310edbbc370f85f131e71cd9a1d75f00412bb580767", size = 138958 }, + { url = "https://files.pythonhosted.org/packages/b5/54/da42bc87dedb20dd9c8b0127f504fd2dd974c598cc6b854f5da27b5a913b/bitarray-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:287029b18624c9dc75af521e1aec4c673d4707d64833375c478eeb22155a3ba9", size = 145819 }, + { url = "https://files.pythonhosted.org/packages/f0/2b/18f680de86ce7e712f9f951a25adf2044bc233206d4b6eaa6584d078bc4b/bitarray-3.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7d5a5020d3586370b6def7f2875409ba2f5b71646c09ef95afb1ecd0f8d98f3", size = 145288 }, + { url = "https://files.pythonhosted.org/packages/c8/05/eaf1ff1be9c8f78ac1c11c835310e3cbc26991aa424fcf0b19aa5ca0d76b/bitarray-3.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63b384f72e3216ebc68f3cbc4e9116064a952e6fd4ba1dbf6546f28905b97b31", size = 142074 }, + { url = "https://files.pythonhosted.org/packages/8f/a3/1691cb49a3eb55b62e61ce8bea8ddd8df8330569c29c4d4bf4799c846a9a/bitarray-3.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b2ce34959316640f5e4a3cbde6714afd8e307bcc6d18655a0c8440fca39134", size = 308442 }, + { url = "https://files.pythonhosted.org/packages/09/5e/99dfd2a9cad307ca3260e49d1894c8d50e0c7caec6cfcb82adc1409e023f/bitarray-3.5.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d093d145acd9d52484dea6a8da078ea33a12b6c63e03e6e11188527d6ebb9237", size = 324997 }, + { url = "https://files.pythonhosted.org/packages/aa/0a/4e16715e294f0d6a0735aca49f058cab06d90d3b7040a3741f4a18ee88de/bitarray-3.5.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc1189241a30cbb409fbba52149d5cb36c02ee003d7394d698d8753bc4f149d5", size = 316801 }, + { url = "https://files.pythonhosted.org/packages/48/ea/2debb71fc4afb91c443714372be68a7fa8231051f9c4c8b8fc9849e10ca8/bitarray-3.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d118b81a66bdee882563542f8ed2537ec440ec2a25cd8ecbc66f309eb6f6189", size = 310428 }, + { url = "https://files.pythonhosted.org/packages/01/a8/dab48f4206106ec6e16a73507f3318a93c01f0d821bf2e3b5ca70209abc1/bitarray-3.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74007d23ed4077b8abfca3abce395e12b5920522efd1334d68391f2b9c05dc47", size = 298473 }, + { url = "https://files.pythonhosted.org/packages/d3/b7/8ce22b3a62139893a302a3ccf2f2ccf1bedeb0760b2345ebbdc684bebde8/bitarray-3.5.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3d433f46de83832d4b4ed40138e0888799200f020184a73a3d98a5fb14478293", size = 303218 }, + { url = "https://files.pythonhosted.org/packages/8d/06/026464c31a7da6b2e6f27f59c9f2cecb10c6d0c063bf0dc60d73a5fd4b4d/bitarray-3.5.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:191cc49c1716ffa3ccc999051359c632f3d2abcada03e9074db202743df22d5b", size = 295525 }, + { url = "https://files.pythonhosted.org/packages/74/63/0395a367bb4312ad8888a8c812ddfb02f8585d6485ec544f187cb40d555f/bitarray-3.5.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:91fdc2e3de67e1ca771b40953d828752d9483364043c1273f4f25218218ad098", size = 320391 }, + { url = "https://files.pythonhosted.org/packages/04/29/b8248ba5c4430c3eabad910df101d4594542f338353eb0d7dee5f08448fa/bitarray-3.5.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2da263cd5ce064673c4bb4957dd78f54f540b1801f5706c122094c6f787742f1", size = 322822 }, + { url = "https://files.pythonhosted.org/packages/77/17/a9ed2cc53449122fbc29baa93f2fb24d8dbd51f0a42f36a0d095fbd69b19/bitarray-3.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:81e6975884621189b55e12eb52dbe31011a6d98606b213916b8401076d4edf87", size = 302602 }, + { url = "https://files.pythonhosted.org/packages/7b/96/9e08109f22ac43326ac68157b288932cb47950daf4d6b5c6800aec02124b/bitarray-3.5.1-cp39-cp39-win32.whl", hash = "sha256:814678bd832117f324d1330a9717435dc1f3a672bcf24ca0f5b568a48218fd96", size = 138666 }, + { url = "https://files.pythonhosted.org/packages/37/d5/3d898be03d7a602dc9dea300719ce1873964e9ece40dbca0a0f1a99607c0/bitarray-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:ba32dce8755860d1cf5915d7e0d149fef3e8957dc38e2a63995e09afd6166895", size = 145201 }, + { url = "https://files.pythonhosted.org/packages/0d/d6/315120866d9842e9a17952db2a91a931dd5cc91cb513b7fdf115459841c3/bitarray-3.5.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c4e862295ad3e3eb728138315dd3e2e2255a5f1a75d234a0990d073dc0905936", size = 140312 }, + { url = "https://files.pythonhosted.org/packages/33/ec/3716031414f5ec87e94371242f9261c19459abc935059358a0c1f1dd793b/bitarray-3.5.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:983be82cfde6c59204f7862ee71c87b999163f366a4ac649121c257f0b73442c", size = 137234 }, + { url = "https://files.pythonhosted.org/packages/3d/dd/0c07e8515e52e462b22ab6760f75fbfe8beb5b8a673dfa938ae1d6e55345/bitarray-3.5.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae197e132d347096ce58c8dee3fa462ace2cc3261286fc63e5a36f843ef2074c", size = 145927 }, + { url = "https://files.pythonhosted.org/packages/56/d8/7011ea2a7d672e66aa2893009229fe10e0da4cf35cba71a5fa95b4d596d0/bitarray-3.5.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d76b6a3c7a8e44607c9bdc3291a95086652068e60e1c9311993b3decad14e38", size = 146683 }, + { url = "https://files.pythonhosted.org/packages/d5/c5/c5c932c001e0764e1e4ca4d62dbd1b4c6dccae9c6ed8da3ac977e4993999/bitarray-3.5.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dd8a6627b55d9ac9224890c678b1d57d4fefa87118717428fd8c4bd2761c20b", size = 148234 }, + { url = "https://files.pythonhosted.org/packages/2e/7f/c6fb356af46e954e3f362d97b8cc19dec4e79bdd373234e8feae309896c3/bitarray-3.5.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:33233ee4c4245b7da2c395468a44473c834eeea7cd53fe97dc419451bf8e6e9a", size = 144113 }, + { url = "https://files.pythonhosted.org/packages/3c/44/63bf3c7c7acb1a78b29224ca3446a50b2391addf47260c8b10645a4dfd18/bitarray-3.5.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6f62aad7a378ead623a122fde6317946152cf8b02260e074d8abbb1f2d14679a", size = 140362 }, + { url = "https://files.pythonhosted.org/packages/cd/f7/f1b9e007e3d99052520914b9edb77be2451a9e6d4814a88cd1c38ecb87d9/bitarray-3.5.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:33794cd0c7326fcb4fd61e8617e512da8b74004e00926dba2240baddaf60122f", size = 137355 }, + { url = "https://files.pythonhosted.org/packages/ae/c4/094c80fbfee4b0213832debc4b1f589cd9c3cb9229e04c27e00f4d7c599a/bitarray-3.5.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20c81f33825b6864ec9c5642d3f8c113226f983b9e846c0fc52a4202a3e29b07", size = 145921 }, + { url = "https://files.pythonhosted.org/packages/20/40/1bdde5c873d0efbc37ce33dade734dfee5430878420a125ede0ef95866aa/bitarray-3.5.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:280fbd967897bcfd35999dd9cc5ef71a98ca1538b186a22d2fba081640ce78d8", size = 146669 }, + { url = "https://files.pythonhosted.org/packages/1c/99/e3c1fb8ae9e8287912acc1d9a5e5b080c1095f033386ffe55eaf1f5ce720/bitarray-3.5.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74c668e14f3ca5aaa1d68164beee277193193462d320caa3b1d644c95165ba1f", size = 148143 }, + { url = "https://files.pythonhosted.org/packages/03/c1/2587b226072c13565d9c5b190e611f3a53df619e2d52b964ed1c62013a83/bitarray-3.5.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:46a79d209f4fce88cc6b1a37066633ab3bbd3a494da3d1365a363d76a785684c", size = 144153 }, +] + +[[package]] +name = "bitchat-python" +source = { editable = "." } +dependencies = [ + { name = "aioconsole" }, + { name = "bleak" }, + { name = "cryptography" }, + { name = "pybloom-live" }, +] + +[package.optional-dependencies] +full = [ + { name = "lz4" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aioconsole", specifier = ">=0.8.1" }, + { name = "bleak", specifier = ">=0.22.3" }, + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "lz4", marker = "extra == 'full'", specifier = ">=4.3.3" }, + { name = "pybloom-live", specifier = ">=4.0.0" }, +] +provides-extras = ["full"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.15.0" }, + { name = "ruff", specifier = ">=0.12.1" }, +] + +[[package]] +name = "bleak" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "dbus-fast", marker = "sys_platform == 'linux'" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-corebluetooth", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-libdispatch", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-advertisement", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-bluetooth-genericattributeprofile", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-devices-enumeration", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-foundation-collections", marker = "sys_platform == 'win32'" }, + { name = "winrt-windows-storage-streams", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/64/84a97528641bf917d0213bb28a1dc0ced916836edc3e342e25c4e8b1a63b/bleak-1.0.1.tar.gz", hash = "sha256:6177487c4eb08743a155e1295ff871eb2a61669b538bdbf35db45ea29cf7a41d", size = 115135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/97/98ba9c8f645f0bf6648b4f5bd997170b3f794d9006c20fdbfe0096c803eb/bleak-1.0.1-py3-none-any.whl", hash = "sha256:8f99bcb2fb74950466622b1f932ab661c74dcd48a3d122e95b661aa0705e5a6a", size = 135281 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, +] + +[[package]] +name = "cryptography" +version = "45.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092 }, + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926 }, + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235 }, + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785 }, + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050 }, + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379 }, + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355 }, + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087 }, + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873 }, + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651 }, + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050 }, + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224 }, + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143 }, + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780 }, + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091 }, + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711 }, + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299 }, + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558 }, + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020 }, + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759 }, + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991 }, + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189 }, + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769 }, + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016 }, + { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762 }, + { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906 }, + { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411 }, + { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942 }, + { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079 }, + { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362 }, + { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878 }, + { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447 }, + { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778 }, + { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627 }, + { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593 }, + { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106 }, +] + +[[package]] +name = "dbus-fast" +version = "2.44.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/a1/9693ec018feed2a7d3420eac6c807eabc6eb84227913104123c0d2ea5737/dbus_fast-2.44.1.tar.gz", hash = "sha256:b027e96c39ed5622bb54d811dcdbbe9d9d6edec3454808a85a1ceb1867d9e25c", size = 72424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/ab0079fb27a2aaefffcb7362f3acd583d3eae6254ba856d51126a2ad6501/dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65a634286651398f3f1326e8200fc54289d52c2c00249d29cacfc691660a5da1", size = 880082 }, + { url = "https://files.pythonhosted.org/packages/7f/d7/f55944dc56f58f051c74cee88104fe49cf243fed3a22d3ebe2d4cf8188c1/dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:0c4a128f8b29941307fc5722f37a1bb87ddcf733188d917ab374d9da0c6e1ce7", size = 948817 }, + { url = "https://files.pythonhosted.org/packages/98/3d/573e98115119950965195b6000cf2cfe2ee762541a08c911dc9a70c8f40f/dbus_fast-2.44.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adaf459fbce22a63d3578f3ec782c6978edf975eb06d71fb5b7a690496cf6bbe", size = 930452 }, + { url = "https://files.pythonhosted.org/packages/2c/84/e1221f862c1e9f78291e724a5296e42e9f0edd712ac194ad50b1e28365ee/dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de871cf722c436bdcceb96b2a3af7084e1fa468f7916ae278ec8ec49a6fa7eef", size = 898007 }, + { url = "https://files.pythonhosted.org/packages/b0/47/a1506ab0571a4a2fc69b646facca04798f1f3812a783f5f9ceb059520b2c/dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b40863de172031bcc02f54c6f05cccb0b882dc2e1b09e11314a8ccf38c558760", size = 983105 }, + { url = "https://files.pythonhosted.org/packages/86/4c/29228db1f8043b2e968198e076efe473490c643812f4b4410c8e946fc96d/dbus_fast-2.44.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b7ae16555df6b56d3befcc51e036779ef47c0e954fdb9fb0821ac25212aefe9", size = 959658 }, + { url = "https://files.pythonhosted.org/packages/c5/ea/a6edb9fa8485f002d8148b9cfe8872dc314a778ea5ae440b8f6d342c4e15/dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ec5db912bd4cfeadf7134163d6dde684271cd44cf26e3b4720107f3de406623", size = 879641 }, + { url = "https://files.pythonhosted.org/packages/63/dd/e83ba0262b4d1f79468151d57e4719ec0ebd8aa1a529075f51bb1a6a661d/dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:6ad99f626837753b39a39e09facd2091ee4851ee1eb6ebec5fa9a9a231734254", size = 938034 }, + { url = "https://files.pythonhosted.org/packages/7b/16/c0ffa2843616e8920800f806de2160a8a07a1c3e884eb7308602e41a5293/dbus_fast-2.44.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7aa157f689a114bfb5367c55884d35e25d57cf25202a6590ce05010f929e7df", size = 927438 }, + { url = "https://files.pythonhosted.org/packages/7b/f6/8e984720ec59d79e7637c43feed1d73ebf81863dc7a516f782ceb14eb1fe/dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f961d8bcad80359f24c0156b3094f58a87d583d56139ee50922fe5894b6797cf", size = 900860 }, + { url = "https://files.pythonhosted.org/packages/a0/4d/95e0ed9003f357c0f2fd18c52cdaf030410bf7bc914dd258258694061aa5/dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1f38fb5c31846c3ada8fc2b693d8d19953d376a9ea21079e3686e93faa1f8a0f", size = 982869 }, + { url = "https://files.pythonhosted.org/packages/2e/9c/2fa2de83e90921addf77f1b2baa3489d2f174c8ccd1c7a59d00303eccade/dbus_fast-2.44.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35e3cde53cc9180ce95c6c84a1e8d1ded429031e4a0a182606e8d22cf57d3294", size = 961978 }, + { url = "https://files.pythonhosted.org/packages/3a/e9/b7b02aa77c66491b87f6720a025ffb99afd6a91c00d3425b221058d3cff6/dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dd0f8d41f6ab9d4a782c116470bc319d690f9b50c97b6debc6d1fef08e4615a", size = 840421 }, + { url = "https://files.pythonhosted.org/packages/35/79/c9bc498e959ae983e1772e4e4ae320342829f21186fd4c6a65369e63c1fc/dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:9d6e386658343db380b9e4e81b3bf4e3c17135dbb5889173b1f2582b675b9a8c", size = 912296 }, + { url = "https://files.pythonhosted.org/packages/cc/a5/948a8cc0861893c6de8746d83cc900e7fd5229b97ed4c9092152b866459e/dbus_fast-2.44.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bd27563c11219b6fde7a5458141d860d8445c2defb036bab360d1f9bf1dfae0", size = 895027 }, + { url = "https://files.pythonhosted.org/packages/c2/d3/daa69f8253a6c41aedf517befdbed514e9cf96ebe7cbcfa5de154acff877/dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0272784aceac821dd63c8187a8860179061a850269617ff5c5bd25ca37bf9307", size = 855338 }, + { url = "https://files.pythonhosted.org/packages/6b/44/adec235f8765a88a7b8ddd49c6592371f7ff126e928d03a98baf4ff1bf9d/dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eed613a909a45f0e0a415c88b373024f007a9be56b1316812ed616d69a3b9161", size = 944282 }, + { url = "https://files.pythonhosted.org/packages/ba/dd/a6f764c46f14214bdab2ab58820b5ff78e234a74246cc6069232d3aaf9e5/dbus_fast-2.44.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0d4288f2cba4f8309dcfd9f4392e0f4f2b5be6c796dfdb0c5e03228b1ab649b1", size = 923505 }, + { url = "https://files.pythonhosted.org/packages/a5/ee/78bf56862fd6ae87998f1ef1d47849a9c5915abb4f0449a72b2c0885482b/dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89dc5db158bf9838979f732acc39e0e1ecd7e3295a09fa8adb93b09c097615a4", size = 834865 }, + { url = "https://files.pythonhosted.org/packages/1b/67/2c0ef231189ff63fa49687f8529ad6bb5afc3bbfda5ba65d9ce3e816cfb8/dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:f11878c0c089d278861e48c02db8002496c2233b0f605b5630ef61f0b7fb0ea3", size = 905859 }, + { url = "https://files.pythonhosted.org/packages/01/ef/9435eae3a658202c4342559b1dad82eb04edfa69fd803325e742c7627c6e/dbus_fast-2.44.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd81f483b3ffb71e88478cfabccc1fab8d7154fccb1c661bfafcff9b0cfd996", size = 888654 }, + { url = "https://files.pythonhosted.org/packages/80/08/9e870f0c4d82f7d6c224f502e51416d9855b2580093bb21b0fc240077a93/dbus_fast-2.44.1-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:ad499de96a991287232749c98a59f2436ed260f6fd9ad4cb3b04a4b1bbbef148", size = 891721 }, + { url = "https://files.pythonhosted.org/packages/53/d2/256fe23f403f8bb22d4fb67b6ad21bcc1c98e4528e2d30a4ae9851fac066/dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36c44286b11e83977cd29f9551b66b446bb6890dff04585852d975aa3a038ca2", size = 850255 }, + { url = "https://files.pythonhosted.org/packages/28/ae/5d9964738bc9a59c9bb01bb4e196c541ed3495895297355c09283934756b/dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:89f2f6eccbb0e464b90e5a8741deb9d6a91873eeb41a8c7b963962b39eb1e0cd", size = 939093 }, + { url = "https://files.pythonhosted.org/packages/f5/3e/1c97abdf0f19ce26ac2f7f18c141495fc7459679d016475f4ad5dedef316/dbus_fast-2.44.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb74a227b071e1a7c517bf3a3e4a5a0a2660620084162e74f15010075534c9d5", size = 915980 }, + { url = "https://files.pythonhosted.org/packages/c7/ec/04c75a244acff834259d8c06f5396a8b28f57a1ace5dc6e86d47b39ee777/dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:806450623ef3f8df846524da7e448edc8174261a01cfd5dfda92e3df89c0de10", size = 882608 }, + { url = "https://files.pythonhosted.org/packages/5e/71/9ff2c8aa5a71b5ed41b8c3890a905c4a1a3ae2bc6d40bf80dcdfe211811e/dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:55ad499b7ef08cb76fce9c9fdcdd6589d2ebfc7e53b3d261d8f40c6d97a8d901", size = 951369 }, + { url = "https://files.pythonhosted.org/packages/af/5f/20255de2009384efb20510b59fd2b7465265eb934f68f8378d91165da92f/dbus_fast-2.44.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55d717865219ec2ae9977b6d067c05261cdc3ef6205c687c8bb92b3437886e58", size = 931079 }, + { url = "https://files.pythonhosted.org/packages/81/68/c082369566e5fbc95f962bd50ff0cba5e3d98ed030b9fe96b640f041a462/dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:39d4cc61e491e11912f76d70cc1c47387ab4f2e5b71f34bfa13eb11aa6026268", size = 899115 }, + { url = "https://files.pythonhosted.org/packages/4a/f1/f4a373471675e7c6541334b6ff98c1967928bceb8a3199126073b339a2e6/dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9b3b10151f1140f7b6dd47a89fc37edd05d6213be0a1748eadba82fc144c05c2", size = 985790 }, + { url = "https://files.pythonhosted.org/packages/63/f6/c510bd4916639a085b157adde944400bd4f100ce61dacfa194dd6320186b/dbus_fast-2.44.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:33772c223f5cef1bacc298e83dc04b27b3a47065b245fde766fcc126e761dca7", size = 961912 }, + { url = "https://files.pythonhosted.org/packages/81/9f/03b8e92fa6655e33b56c5172c9ccc95093850986ab706bd235b91bdc5653/dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f29a81d86c9ce3020a5df8c1e5557edaa00e1e00c9804ec874d46c99d967a686", size = 720870 }, + { url = "https://files.pythonhosted.org/packages/39/af/c8ef58a4a4584db520b654731bfe35923c73bb769386411d8fecdeb016e3/dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5dec134715457601c0fa8df3040a56d319de1a152464ae4d4bfc53bbb5c02e04", size = 767585 }, + { url = "https://files.pythonhosted.org/packages/8c/e2/9e3ef29e40b44556f16756cd70ae8e7ec3b5fceec1b78c5e4849ed15f926/dbus_fast-2.44.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:893509b516f2f24b4e3f09a6b1f3a30f856cf237cd773cdc505ea7ab4fa3c863", size = 754941 }, + { url = "https://files.pythonhosted.org/packages/bc/fc/1d6d3a00f0f6a93fc05ecced651b635932d5a37407b44d27a7dbf5f7c3bf/dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:161a3e6fc8783c30c9feb072e09604d96ec0c465b06bd35b6acc1a0316bd2a27", size = 721094 }, + { url = "https://files.pythonhosted.org/packages/0a/3a/a37242eb170b0da9ef570d7da699420815e000a534b6c52d346ffa14f0a7/dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:67febe6454e714d85a532bd84969001ed948bbaf1699a7e1e4c6abb5508c9522", size = 768266 }, + { url = "https://files.pythonhosted.org/packages/bf/77/f1d6afe5a05cbc3fed3d8a96231548664427c341f4bf35f5c7318c0be665/dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890f0fc046d5db66524ddedeca8c14b65739fbbf32d6488175c07428362bf250", size = 754540 }, +] + +[[package]] +name = "lz4" +version = "4.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/5a/945f5086326d569f14c84ac6f7fcc3229f0b9b1e8cc536b951fd53dfb9e1/lz4-4.4.4.tar.gz", hash = "sha256:070fd0627ec4393011251a094e08ed9fdcc78cb4e7ab28f507638eee4e39abda", size = 171884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/80/4054e99cda2e003097f59aeb3ad470128f3298db5065174a84564d2d6983/lz4-4.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f170abb8416c4efca48e76cac2c86c3185efdf841aecbe5c190121c42828ced0", size = 220896 }, + { url = "https://files.pythonhosted.org/packages/dd/4e/f92424d5734e772b05ddbeec739e2566e2a2336995b36a180e1dd9411e9a/lz4-4.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d33a5105cd96ebd32c3e78d7ece6123a9d2fb7c18b84dec61f27837d9e0c496c", size = 189679 }, + { url = "https://files.pythonhosted.org/packages/a2/70/71ffd496067cba6ba352e10b89c0e9cee3e4bc4717ba866b6aa350f4c7ac/lz4-4.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30ebbc5b76b4f0018988825a7e9ce153be4f0d4eba34e6c1f2fcded120573e88", size = 1237940 }, + { url = "https://files.pythonhosted.org/packages/6e/59/cf34d1e232b11e1ae7122300be00529f369a7cd80f74ac351d58c4c4eedf/lz4-4.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc64d6dfa7a89397529b22638939e70d85eaedc1bd68e30a29c78bfb65d4f715", size = 1264105 }, + { url = "https://files.pythonhosted.org/packages/f9/f6/3a00a98ff5b872d572cc6e9c88e0f6275bea0f3ed1dc1b8f8b736c85784c/lz4-4.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a355223a284f42a723c120ce68827de66d5cb872a38732b3d5abbf544fa2fe26", size = 1184179 }, + { url = "https://files.pythonhosted.org/packages/bc/de/6aeb602786174bad290609c0c988afb1077b74a80eaea23ebc3b5de6e2fa/lz4-4.4.4-cp310-cp310-win32.whl", hash = "sha256:b28228197775b7b5096898851d59ef43ccaf151136f81d9c436bc9ba560bc2ba", size = 88265 }, + { url = "https://files.pythonhosted.org/packages/e4/b5/1f52c8b17d02ae637f85911c0135ca08be1c9bbdfb3e7de1c4ae7af0bac6/lz4-4.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:45e7c954546de4f85d895aa735989d77f87dd649f503ce1c8a71a151b092ed36", size = 99916 }, + { url = "https://files.pythonhosted.org/packages/01/e7/123587e7dae6cdba48393e4fdad2b9412f43f51346afe9ca6f697029de11/lz4-4.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:e3fc90f766401684740978cd781d73b9685bd81b5dbf7257542ef9de4612e4d2", size = 89746 }, + { url = "https://files.pythonhosted.org/packages/28/e8/63843dc5ecb1529eb38e1761ceed04a0ad52a9ad8929ab8b7930ea2e4976/lz4-4.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ddfc7194cd206496c445e9e5b0c47f970ce982c725c87bd22de028884125b68f", size = 220898 }, + { url = "https://files.pythonhosted.org/packages/e4/94/c53de5f07c7dc11cf459aab2a1d754f5df5f693bfacbbe1e4914bfd02f1e/lz4-4.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:714f9298c86f8e7278f1c6af23e509044782fa8220eb0260f8f8f1632f820550", size = 189685 }, + { url = "https://files.pythonhosted.org/packages/fe/59/c22d516dd0352f2a3415d1f665ccef2f3e74ecec3ca6a8f061a38f97d50d/lz4-4.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8474c91de47733856c6686df3c4aca33753741da7e757979369c2c0d32918ba", size = 1239225 }, + { url = "https://files.pythonhosted.org/packages/81/af/665685072e71f3f0e626221b7922867ec249cd8376aca761078c8f11f5da/lz4-4.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80dd27d7d680ea02c261c226acf1d41de2fd77af4fb2da62b278a9376e380de0", size = 1265881 }, + { url = "https://files.pythonhosted.org/packages/90/04/b4557ae381d3aa451388a29755cc410066f5e2f78c847f66f154f4520a68/lz4-4.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9b7d6dddfd01b49aedb940fdcaf32f41dc58c926ba35f4e31866aeec2f32f4f4", size = 1185593 }, + { url = "https://files.pythonhosted.org/packages/7b/e4/03636979f4e8bf92c557f998ca98ee4e6ef92e92eaf0ed6d3c7f2524e790/lz4-4.4.4-cp311-cp311-win32.whl", hash = "sha256:4134b9fd70ac41954c080b772816bb1afe0c8354ee993015a83430031d686a4c", size = 88259 }, + { url = "https://files.pythonhosted.org/packages/07/f0/9efe53b4945441a5d2790d455134843ad86739855b7e6199977bf6dc8898/lz4-4.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:f5024d3ca2383470f7c4ef4d0ed8eabad0b22b23eeefde1c192cf1a38d5e9f78", size = 99916 }, + { url = "https://files.pythonhosted.org/packages/87/c8/1675527549ee174b9e1db089f7ddfbb962a97314657269b1e0344a5eaf56/lz4-4.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:6ea715bb3357ea1665f77874cf8f55385ff112553db06f3742d3cdcec08633f7", size = 89741 }, + { url = "https://files.pythonhosted.org/packages/f7/2d/5523b4fabe11cd98f040f715728d1932eb7e696bfe94391872a823332b94/lz4-4.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:23ae267494fdd80f0d2a131beff890cf857f1b812ee72dbb96c3204aab725553", size = 220669 }, + { url = "https://files.pythonhosted.org/packages/91/06/1a5bbcacbfb48d8ee5b6eb3fca6aa84143a81d92946bdb5cd6b005f1863e/lz4-4.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fff9f3a1ed63d45cb6514bfb8293005dc4141341ce3500abdfeb76124c0b9b2e", size = 189661 }, + { url = "https://files.pythonhosted.org/packages/fa/08/39eb7ac907f73e11a69a11576a75a9e36406b3241c0ba41453a7eb842abb/lz4-4.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ea7f07329f85a8eda4d8cf937b87f27f0ac392c6400f18bea2c667c8b7f8ecc", size = 1238775 }, + { url = "https://files.pythonhosted.org/packages/e9/26/05840fbd4233e8d23e88411a066ab19f1e9de332edddb8df2b6a95c7fddc/lz4-4.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ccab8f7f7b82f9fa9fc3b0ba584d353bd5aa818d5821d77d5b9447faad2aaad", size = 1265143 }, + { url = "https://files.pythonhosted.org/packages/b7/5d/5f2db18c298a419932f3ab2023deb689863cf8fd7ed875b1c43492479af2/lz4-4.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43e9d48b2daf80e486213128b0763deed35bbb7a59b66d1681e205e1702d735", size = 1185032 }, + { url = "https://files.pythonhosted.org/packages/c4/e6/736ab5f128694b0f6aac58343bcf37163437ac95997276cd0be3ea4c3342/lz4-4.4.4-cp312-cp312-win32.whl", hash = "sha256:33e01e18e4561b0381b2c33d58e77ceee850a5067f0ece945064cbaac2176962", size = 88284 }, + { url = "https://files.pythonhosted.org/packages/40/b8/243430cb62319175070e06e3a94c4c7bd186a812e474e22148ae1290d47d/lz4-4.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d21d1a2892a2dcc193163dd13eaadabb2c1b803807a5117d8f8588b22eaf9f12", size = 99918 }, + { url = "https://files.pythonhosted.org/packages/6c/e1/0686c91738f3e6c2e1a243e0fdd4371667c4d2e5009b0a3605806c2aa020/lz4-4.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:2f4f2965c98ab254feddf6b5072854a6935adab7bc81412ec4fe238f07b85f62", size = 89736 }, + { url = "https://files.pythonhosted.org/packages/3b/3c/d1d1b926d3688263893461e7c47ed7382a969a0976fc121fc678ec325fc6/lz4-4.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed6eb9f8deaf25ee4f6fad9625d0955183fdc90c52b6f79a76b7f209af1b6e54", size = 220678 }, + { url = "https://files.pythonhosted.org/packages/26/89/8783d98deb058800dabe07e6cdc90f5a2a8502a9bad8c5343c641120ace2/lz4-4.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:18ae4fe3bafb344dbd09f976d45cbf49c05c34416f2462828f9572c1fa6d5af7", size = 189670 }, + { url = "https://files.pythonhosted.org/packages/22/ab/a491ace69a83a8914a49f7391e92ca0698f11b28d5ce7b2ececa2be28e9a/lz4-4.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57fd20c5fc1a49d1bbd170836fccf9a338847e73664f8e313dce6ac91b8c1e02", size = 1238746 }, + { url = "https://files.pythonhosted.org/packages/97/12/a1f2f4fdc6b7159c0d12249456f9fe454665b6126e98dbee9f2bd3cf735c/lz4-4.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9cb387c33f014dae4db8cb4ba789c8d2a0a6d045ddff6be13f6c8d9def1d2a6", size = 1265119 }, + { url = "https://files.pythonhosted.org/packages/50/6e/e22e50f5207649db6ea83cd31b79049118305be67e96bec60becf317afc6/lz4-4.4.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0be9f68240231e1e44118a4ebfecd8a5d4184f0bdf5c591c98dd6ade9720afd", size = 1184954 }, + { url = "https://files.pythonhosted.org/packages/4c/c4/2a458039645fcc6324ece731d4d1361c5daf960b553d1fcb4261ba07d51c/lz4-4.4.4-cp313-cp313-win32.whl", hash = "sha256:e9ec5d45ea43684f87c316542af061ef5febc6a6b322928f059ce1fb289c298a", size = 88289 }, + { url = "https://files.pythonhosted.org/packages/00/96/b8e24ea7537ab418074c226279acfcaa470e1ea8271003e24909b6db942b/lz4-4.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:a760a175b46325b2bb33b1f2bbfb8aa21b48e1b9653e29c10b6834f9bb44ead4", size = 99925 }, + { url = "https://files.pythonhosted.org/packages/a5/a5/f9838fe6aa132cfd22733ed2729d0592259fff074cefb80f19aa0607367b/lz4-4.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:f4c21648d81e0dda38b4720dccc9006ae33b0e9e7ffe88af6bf7d4ec124e2fba", size = 89743 }, + { url = "https://files.pythonhosted.org/packages/60/92/84d57db743cef59b2277cf40577de12ab48cbcd327772273a81a39d70580/lz4-4.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bd1add57b6fe1f96bed2d529de085e9378a3ac04b86f116d10506f85b68e97fc", size = 220887 }, + { url = "https://files.pythonhosted.org/packages/87/b7/afa1ba2f827c1ec9d0b571e4fc71a2357a7fc735430cfb9b4c03f94f5d8a/lz4-4.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:900912e8a7cf74b4a2bea18a3594ae0bf1138f99919c20017167b6e05f760aa4", size = 189664 }, + { url = "https://files.pythonhosted.org/packages/6f/6b/bfa74d3412cc9a5c787b44e1941b3186a813f8354ea633aed785ad8af106/lz4-4.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017f8d269a739405a59d68a4d63d23a8df23e3bb2c70aa069b7563af08dfdffb", size = 1237318 }, + { url = "https://files.pythonhosted.org/packages/b8/c7/3e826333be0034ac702a6fa25ed289002c83a154c69d8b9da7f3583157c5/lz4-4.4.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac522788296a9a02a39f620970dea86c38e141e21e51238f1b5e9fa629f8e69", size = 1263452 }, + { url = "https://files.pythonhosted.org/packages/45/e5/06b90dbe76f21475aab0052e0f1a8598d651a5a269f2e9a86f05241142ed/lz4-4.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b56aa9eef830bf6443acd8c4e18b208a8993dc32e0d6ef4263ecfa6afb3f599", size = 1183617 }, + { url = "https://files.pythonhosted.org/packages/0b/9e/1781ecb72300aed4d74484dff9a16a585e82c5de47c52930bb6e5a415c40/lz4-4.4.4-cp39-cp39-win32.whl", hash = "sha256:585b42eb37ab16a278c3a917ec23b2beef175aa669f4120142b97aebf90ef775", size = 88266 }, + { url = "https://files.pythonhosted.org/packages/f8/2d/2426270bc39cd39a49773559137415b5a1bb43d3be75a44807806f5cb503/lz4-4.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:4ab1537bd3b3bfbafd3c8847e06827129794488304f21945fc2f5b669649d94f", size = 99915 }, + { url = "https://files.pythonhosted.org/packages/66/ea/cadcd430073925e1acd7c509333101cc1b922593af6779355bf6cc064c73/lz4-4.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:38730927ad51beb42ab8dbc5555270bfbe86167ba734265f88bbd799fced1004", size = 89747 }, +] + +[[package]] +name = "mypy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/31/e762baa3b73905c856d45ab77b4af850e8159dffffd86a52879539a08c6b/mypy-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8e08de6138043108b3b18f09d3f817a4783912e48828ab397ecf183135d84d6", size = 10998313 }, + { url = "https://files.pythonhosted.org/packages/1c/c1/25b2f0d46fb7e0b5e2bee61ec3a47fe13eff9e3c2f2234f144858bbe6485/mypy-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce4a17920ec144647d448fc43725b5873548b1aae6c603225626747ededf582d", size = 10128922 }, + { url = "https://files.pythonhosted.org/packages/02/78/6d646603a57aa8a2886df1b8881fe777ea60f28098790c1089230cd9c61d/mypy-1.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff25d151cc057fdddb1cb1881ef36e9c41fa2a5e78d8dd71bee6e4dcd2bc05b", size = 11913524 }, + { url = "https://files.pythonhosted.org/packages/4f/19/dae6c55e87ee426fb76980f7e78484450cad1c01c55a1dc4e91c930bea01/mypy-1.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93468cf29aa9a132bceb103bd8475f78cacde2b1b9a94fd978d50d4bdf616c9a", size = 12650527 }, + { url = "https://files.pythonhosted.org/packages/86/e1/f916845a235235a6c1e4d4d065a3930113767001d491b8b2e1b61ca56647/mypy-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:98189382b310f16343151f65dd7e6867386d3e35f7878c45cfa11383d175d91f", size = 12897284 }, + { url = "https://files.pythonhosted.org/packages/ae/dc/414760708a4ea1b096bd214d26a24e30ac5e917ef293bc33cdb6fe22d2da/mypy-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:c004135a300ab06a045c1c0d8e3f10215e71d7b4f5bb9a42ab80236364429937", size = 9506493 }, + { url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150 }, + { url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845 }, + { url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246 }, + { url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106 }, + { url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960 }, + { url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888 }, + { url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395 }, + { url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052 }, + { url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806 }, + { url = "https://files.pythonhosted.org/packages/0b/64/d6120eca3835baf7179e6797a0b61d6c47e0bc2324b1f6819d8428d5b9ba/mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e", size = 12744371 }, + { url = "https://files.pythonhosted.org/packages/1f/dc/56f53b5255a166f5bd0f137eed960e5065f2744509dfe69474ff0ba772a5/mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8", size = 12914558 }, + { url = "https://files.pythonhosted.org/packages/69/ac/070bad311171badc9add2910e7f89271695a25c136de24bbafc7eded56d5/mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d", size = 9585447 }, + { url = "https://files.pythonhosted.org/packages/be/7b/5f8ab461369b9e62157072156935cec9d272196556bdc7c2ff5f4c7c0f9b/mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06", size = 11070019 }, + { url = "https://files.pythonhosted.org/packages/9c/f8/c49c9e5a2ac0badcc54beb24e774d2499748302c9568f7f09e8730e953fa/mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a", size = 10114457 }, + { url = "https://files.pythonhosted.org/packages/89/0c/fb3f9c939ad9beed3e328008b3fb90b20fda2cddc0f7e4c20dbefefc3b33/mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889", size = 11857838 }, + { url = "https://files.pythonhosted.org/packages/4c/66/85607ab5137d65e4f54d9797b77d5a038ef34f714929cf8ad30b03f628df/mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba", size = 12731358 }, + { url = "https://files.pythonhosted.org/packages/73/d0/341dbbfb35ce53d01f8f2969facbb66486cee9804048bf6c01b048127501/mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658", size = 12917480 }, + { url = "https://files.pythonhosted.org/packages/64/63/70c8b7dbfc520089ac48d01367a97e8acd734f65bd07813081f508a8c94c/mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c", size = 9589666 }, + { url = "https://files.pythonhosted.org/packages/9f/a0/6263dd11941231f688f0a8f2faf90ceac1dc243d148d314a089d2fe25108/mypy-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:63e751f1b5ab51d6f3d219fe3a2fe4523eaa387d854ad06906c63883fde5b1ab", size = 10988185 }, + { url = "https://files.pythonhosted.org/packages/02/13/b8f16d6b0dc80277129559c8e7dbc9011241a0da8f60d031edb0e6e9ac8f/mypy-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f7fb09d05e0f1c329a36dcd30e27564a3555717cde87301fae4fb542402ddfad", size = 10120169 }, + { url = "https://files.pythonhosted.org/packages/14/ef/978ba79df0d65af680e20d43121363cf643eb79b04bf3880d01fc8afeb6f/mypy-1.17.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b72c34ce05ac3a1361ae2ebb50757fb6e3624032d91488d93544e9f82db0ed6c", size = 11918121 }, + { url = "https://files.pythonhosted.org/packages/f4/10/55ef70b104151a0d8280474f05268ff0a2a79be8d788d5e647257d121309/mypy-1.17.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434ad499ad8dde8b2f6391ddfa982f41cb07ccda8e3c67781b1bfd4e5f9450a8", size = 12648821 }, + { url = "https://files.pythonhosted.org/packages/26/8c/7781fcd2e1eef48fbedd3a422c21fe300a8e03ed5be2eb4bd10246a77f4e/mypy-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f105f61a5eff52e137fd73bee32958b2add9d9f0a856f17314018646af838e97", size = 12896955 }, + { url = "https://files.pythonhosted.org/packages/78/13/03ac759dabe86e98ca7b6681f114f90ee03f3ff8365a57049d311bd4a4e3/mypy-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:ba06254a5a22729853209550d80f94e28690d5530c661f9416a68ac097b13fc4", size = 9512957 }, + { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pybloom-live" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bitarray" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/06/868053bdca7afcc22905d6fa5f515880c31cbb12437aea1814c26cdd1c92/pybloom_live-4.0.0.tar.gz", hash = "sha256:99545c5d3b05bd388b5491e36b823b706830a686ba18b4c19063d08de5321110", size = 10142 } + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pyobjc-core" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/e9/0b85c81e2b441267bca707b5d89f56c2f02578ef8f3eafddf0e0c0b8848c/pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe", size = 974602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/c5/9fa74ef6b83924e657c5098d37b36b66d1e16d13bc45c44248c6248e7117/pyobjc_core-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4c7536f3e94de0a3eae6bb382d75f1219280aa867cdf37beef39d9e7d580173c", size = 676323 }, + { url = "https://files.pythonhosted.org/packages/5a/a7/55afc166d89e3fcd87966f48f8bca3305a3a2d7c62100715b9ffa7153a90/pyobjc_core-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ec36680b5c14e2f73d432b03ba7c1457dc6ca70fa59fd7daea1073f2b4157d33", size = 671075 }, + { url = "https://files.pythonhosted.org/packages/c0/09/e83228e878e73bf756749939f906a872da54488f18d75658afa7f1abbab1/pyobjc_core-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:765b97dea6b87ec4612b3212258024d8496ea23517c95a1c5f0735f96b7fd529", size = 677985 }, + { url = "https://files.pythonhosted.org/packages/c5/24/12e4e2dae5f85fd0c0b696404ed3374ea6ca398e7db886d4f1322eb30799/pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c", size = 676431 }, + { url = "https://files.pythonhosted.org/packages/f7/79/031492497624de4c728f1857181b06ce8c56444db4d49418fa459cba217c/pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2", size = 719330 }, + { url = "https://files.pythonhosted.org/packages/ed/7d/6169f16a0c7ec15b9381f8bf33872baf912de2ef68d96c798ca4c6ee641f/pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731", size = 667203 }, + { url = "https://files.pythonhosted.org/packages/49/0f/f5ab2b0e57430a3bec9a62b6153c0e79c05a30d77b564efdb9f9446eeac5/pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96", size = 708807 }, + { url = "https://files.pythonhosted.org/packages/0b/3c/98f04333e4f958ee0c44ceccaf0342c2502d361608e00f29a5d50e16a569/pyobjc_core-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4a99e6558b48b8e47c092051e7b3be05df1c8d0617b62f6fa6a316c01902d157", size = 677089 }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/c5/7a866d24bc026f79239b74d05e2cf3088b03263da66d53d1b4cf5207f5ae/pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038", size = 5565335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/8f/67a7e166b615feb96385d886c6732dfb90afed565b8b1f34673683d73cd9/pyobjc_framework_cocoa-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b27a5bdb3ab6cdeb998443ff3fce194ffae5f518c6a079b832dbafc4426937f9", size = 388187 }, + { url = "https://files.pythonhosted.org/packages/90/43/6841046aa4e257b6276cd23e53cacedfb842ecaf3386bb360fa9cc319aa1/pyobjc_framework_cocoa-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b9a9b8ba07f5bf84866399e3de2aa311ed1c34d5d2788a995bdbe82cc36cfa0", size = 388177 }, + { url = "https://files.pythonhosted.org/packages/68/da/41c0f7edc92ead461cced7e67813e27fa17da3c5da428afdb4086c69d7ba/pyobjc_framework_cocoa-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806de56f06dfba8f301a244cce289d54877c36b4b19818e3b53150eb7c2424d0", size = 388983 }, + { url = "https://files.pythonhosted.org/packages/4e/0b/a01477cde2a040f97e226f3e15e5ffd1268fcb6d1d664885a95ba592eca9/pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da", size = 389049 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/64cf2661f6ab7c124d0486ec6d1d01a9bb2838a0d2a46006457d8c5e6845/pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350", size = 393110 }, + { url = "https://files.pythonhosted.org/packages/33/87/01e35c5a3c5bbdc93d5925366421e10835fcd7b23347b6c267df1b16d0b3/pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0", size = 392644 }, + { url = "https://files.pythonhosted.org/packages/c1/7c/54afe9ffee547c41e1161691e72067a37ed27466ac71c089bfdcd07ca70d/pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71", size = 396742 }, + { url = "https://files.pythonhosted.org/packages/b2/9b/5499d1ed6790b037b12831d7038eb21031ab90a033d4cfa43c9b51085925/pyobjc_framework_cocoa-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bbee71eeb93b1b31ffbac8560b59a0524a8a4b90846a260d2c4f2188f3d4c721", size = 388163 }, +] + +[[package]] +name = "pyobjc-framework-corebluetooth" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fe/2081dfd9413b7b4d719935c33762fbed9cce9dc06430f322d1e2c9dbcd91/pyobjc_framework_corebluetooth-11.1.tar.gz", hash = "sha256:1deba46e3fcaf5e1c314f4bbafb77d9fe49ec248c493ad00d8aff2df212d6190", size = 60337 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/93/5b5ec131a238238ac1190758ccc5731b127e05e94a46abd08c5e1094cab9/pyobjc_framework_corebluetooth-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ab509994503a5f0ec0f446a7ccc9f9a672d5a427d40dba4563dd00e8e17dfb06", size = 13140 }, + { url = "https://files.pythonhosted.org/packages/8c/75/3318e85b7328c99c752e40592a907fc5c755cddc6d73beacbb432f6aa2d0/pyobjc_framework_corebluetooth-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:433b8593eb1ea8b6262b243ec903e1de4434b768ce103ebe15aac249b890cc2a", size = 13143 }, + { url = "https://files.pythonhosted.org/packages/8a/bc/083ea1ae57a31645df7fad59921528f6690995f7b7c84a203399ded7e7fe/pyobjc_framework_corebluetooth-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:36bef95a822c68b72f505cf909913affd61a15b56eeaeafea7302d35a82f4f05", size = 13163 }, + { url = "https://files.pythonhosted.org/packages/3e/b5/d07cfa229e3fa0cd1cdaa385774c41907941d25b693cf55ad92e8584a3b3/pyobjc_framework_corebluetooth-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:992404b03033ecf637e9174caed70cb22fd1be2a98c16faa699217678e62a5c7", size = 13179 }, + { url = "https://files.pythonhosted.org/packages/7a/10/476bca43002a6d009aed956d5ed3f3867c8d1dcd085dde8989be7020c495/pyobjc_framework_corebluetooth-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ebb8648f5e33d98446eb1d6c4654ba4fcc15d62bfcb47fa3bbd5596f6ecdb37c", size = 13358 }, + { url = "https://files.pythonhosted.org/packages/b0/49/6c050dffb9acc49129da54718c545bc5062f61a389ebaa4727bc3ef0b5a9/pyobjc_framework_corebluetooth-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e84cbf52006a93d937b90421ada0bc4a146d6d348eb40ae10d5bd2256cc92206", size = 13245 }, + { url = "https://files.pythonhosted.org/packages/36/15/9068e8cb108e19e8e86cbf50026bb4c509d85a5d55e2d4c36e292be94337/pyobjc_framework_corebluetooth-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:4da1106265d7efd3f726bacdf13ba9528cc380fb534b5af38b22a397e6908291", size = 13439 }, + { url = "https://files.pythonhosted.org/packages/2c/4b/2d36b7efe08a6d9004f205ac7ad4348805a447a31a4feec6cd08af9d64fe/pyobjc_framework_corebluetooth-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e9fa3781fea20a31b3bb809deaeeab3bdc7b86602a1fd829f0e86db11d7aa577", size = 13136 }, +] + +[[package]] +name = "pyobjc-framework-libdispatch" +version = "11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/89/7830c293ba71feb086cb1551455757f26a7e2abd12f360d375aae32a4d7d/pyobjc_framework_libdispatch-11.1.tar.gz", hash = "sha256:11a704e50a0b7dbfb01552b7d686473ffa63b5254100fdb271a1fe368dd08e87", size = 53942 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/33/7a6b509e85d95ed5aa7c813c6bccfe4e0a1162baa02f51050d1da91408a9/pyobjc_framework_libdispatch-11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c598c073a541b5956b5457b94bd33b9ce19ef8d867235439a0fad22d6beab49", size = 20444 }, + { url = "https://files.pythonhosted.org/packages/b0/cd/1010dee9f932a9686c27ce2e45e91d5b6875f5f18d2daafadea70090e111/pyobjc_framework_libdispatch-11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ddca472c2cbc6bb192e05b8b501d528ce49333abe7ef0eef28df3133a8e18b7", size = 20441 }, + { url = "https://files.pythonhosted.org/packages/ac/92/ff9ceb14e1604193dcdb50643f2578e1010c68556711cd1a00eb25489c2b/pyobjc_framework_libdispatch-11.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc9a7b8c2e8a63789b7cf69563bb7247bde15353208ef1353fff0af61b281684", size = 15627 }, + { url = "https://files.pythonhosted.org/packages/0f/10/5851b68cd85b475ff1da08e908693819fd9a4ff07c079da9b0b6dbdaca9c/pyobjc_framework_libdispatch-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c4e219849f5426745eb429f3aee58342a59f81e3144b37aa20e81dacc6177de1", size = 15648 }, + { url = "https://files.pythonhosted.org/packages/1b/79/f905f22b976e222a50d49e85fbd7f32d97e8790dd80a55f3f0c305305c32/pyobjc_framework_libdispatch-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a9357736cb47b4a789f59f8fab9b0d10b0a9c84f9876367c398718d3de085888", size = 15912 }, + { url = "https://files.pythonhosted.org/packages/ee/b0/225a3645ba2711c3122eec3e857ea003646643b4122bd98db2a8831740ff/pyobjc_framework_libdispatch-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:cd08f32ea7724906ef504a0fd40a32e2a0be4d64b9239530a31767ca9ccfc921", size = 15655 }, + { url = "https://files.pythonhosted.org/packages/e2/b5/ff49fb81f13c7ec48cd7ccad66e1986ccc6aa1984e04f4a78074748f7926/pyobjc_framework_libdispatch-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:5d9985b0e050cae72bf2c6a1cc8180ff4fa3a812cd63b2dc59e09c6f7f6263a1", size = 15920 }, + { url = "https://files.pythonhosted.org/packages/73/4c/4ef43d2ee85e55a73cfb5090cf29d2f1a5d82e6fe81623b62b7e008afe33/pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4", size = 20435 }, +] + +[[package]] +name = "ruff" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499 }, + { url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413 }, + { url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941 }, + { url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001 }, + { url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641 }, + { url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059 }, + { url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890 }, + { url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008 }, + { url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096 }, + { url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307 }, + { url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020 }, + { url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300 }, + { url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119 }, + { url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990 }, + { url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263 }, + { url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072 }, + { url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 }, +] + +[[package]] +name = "winrt-runtime" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/28/26d86ca6d2f155f31ca61e069312034a8922a5a89f5d0fc68abb7c04aad1/winrt_runtime-3.2.1-cp310-cp310-win32.whl", hash = "sha256:25a2d1e2b45423742319f7e10fa8ca2e7063f01284b6e85e99d805c4b50bbfb3", size = 210993 }, + { url = "https://files.pythonhosted.org/packages/46/a4/f096687e0d1877d206bc5d1f5f07ff90e00b0772d69d4559ab2b6b37090b/winrt_runtime-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:dc81d5fb736bf1ddecf743928622253dce4d0aac9a57faad776d7a3834e13257", size = 242210 }, + { url = "https://files.pythonhosted.org/packages/ff/81/46927ce4d79fc8f40f193f35204bce79eff7c496d888825a7a74d8560b6e/winrt_runtime-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:363f584b1e9fcb601e3e178636d8877e6f0537ac3c96ce4a96f06066f8ff0eae", size = 415833 }, + { url = "https://files.pythonhosted.org/packages/90/8d/d7ae0e07cd85c7768de76e8578261854f2af72bd3a8a527bb675e8ae0eda/winrt_runtime-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9e9b64f1ba631cc4b9fe60b8ff16fef3f32c7ce2fcc84735a63129ff8b15c022", size = 210798 }, + { url = "https://files.pythonhosted.org/packages/ac/66/d05f6e6c0517654734e7f87fa1f0fbc965add9f27cc36b524d96331ab3d8/winrt_runtime-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c0a9046ae416808420a358c51705af8ae100acd40bc578be57ddfdd51cbb0f9c", size = 242032 }, + { url = "https://files.pythonhosted.org/packages/39/a5/760c8396110f6d3e4c417752da1a2bf3b89e0998329c2f10afc717ef6291/winrt_runtime-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:e94f3cb40ea2d723c44c82c16d715c03c6b3bd977d135b49535fdd5415fd9130", size = 415659 }, + { url = "https://files.pythonhosted.org/packages/d3/54/3dd06f2341fab6abb06588a16b30e0b213b0125be7b79dafc3bdba3b334a/winrt_runtime-3.2.1-cp312-cp312-win32.whl", hash = "sha256:762b3d972a2f7037f7db3acbaf379dd6d8f6cda505f71f66c6b425d1a1eae2f1", size = 210090 }, + { url = "https://files.pythonhosted.org/packages/ca/a1/1d7248d5c62ccbea5f3e0da64ca4529ce99c639c3be2485b6ed709f5c740/winrt_runtime-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:06510db215d4f0dc45c00fbb1251c6544e91742a0ad928011db33b30677e1576", size = 241391 }, + { url = "https://files.pythonhosted.org/packages/8a/ae/6a205d8dafc79f7c242be7f940b1e0c1971fd64ab3079bda4b514aa3d714/winrt_runtime-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:14562c29a087ccad38e379e585fef333e5c94166c807bdde67b508a6261aa195", size = 415242 }, + { url = "https://files.pythonhosted.org/packages/79/d4/1a555d8bdcb8b920f8e896232c82901cc0cda6d3e4f92842199ae7dff70a/winrt_runtime-3.2.1-cp313-cp313-win32.whl", hash = "sha256:44e2733bc709b76c554aee6c7fe079443b8306b2e661e82eecfebe8b9d71e4d1", size = 210022 }, + { url = "https://files.pythonhosted.org/packages/aa/24/2b6e536ca7745d788dfd17a2ec376fa03a8c7116dc638bb39b035635484f/winrt_runtime-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c1fdcaeedeb2920dc3b9039db64089a6093cad2be56a3e64acc938849245a6d", size = 241349 }, + { url = "https://files.pythonhosted.org/packages/d4/7f/6d72973279e2929b2a71ed94198ad4a5d63ee2936e91a11860bf7b431410/winrt_runtime-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:28f3dab083412625ff4d2b46e81246932e6bebddf67bea7f05e01712f54e6159", size = 415126 }, + { url = "https://files.pythonhosted.org/packages/31/12/f8a79bd0cdf1db78735619016b1b7f5efe8f138207a621edec9aae58f846/winrt_runtime-3.2.1-cp39-cp39-win32.whl", hash = "sha256:07c0cb4a53a4448c2cb7597b62ae8c94343c289eeebd8f83f946eb2c817bde01", size = 211013 }, + { url = "https://files.pythonhosted.org/packages/6f/7d/1e7da43fd4dab3a7b181c5c5dde547f877dab391b7d34a11a835dd3ea616/winrt_runtime-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1856325ca3354b45e0789cf279be9a882134085d34214946db76110d98391efa", size = 242293 }, + { url = "https://files.pythonhosted.org/packages/27/5e/dafd643a8ece50f3136dfb6d8d5bcbc10601f11bc09fdd87df5de8994889/winrt_runtime-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:cf237858de1d62e4c9b132c66b52028a7a3e8534e8ab90b0e29a68f24f7be39d", size = 415952 }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/b7/822da8bc0b6a67cc0c3e460fef793f00c51a6fe59aa54f6bfe416519a9d9/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win32.whl", hash = "sha256:49489351037094a088a08fbdf0f99c94e3299b574edb211f717c4c727770af78", size = 105569 }, + { url = "https://files.pythonhosted.org/packages/68/46/696893d3bae80751e35fb0fb8fae5e7fc94a5354dfb5e19167d415e27c66/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:20f6a21029034c18ea6a6b6df399671813b071102a0d6d8355bb78cf4f547cdb", size = 114743 }, + { url = "https://files.pythonhosted.org/packages/5b/6a/a36b28739b73cc2c67050da866b063af135b5f6c071997c85a27adb6815c/winrt_windows_devices_bluetooth-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c523814eab795bc1bf913292309cb1025ef0a67d5fc33863a98788995e551d", size = 105021 }, + { url = "https://files.pythonhosted.org/packages/3b/cf/671bf29337323cc08f9969cb32312f217d2927d29dbf2964f0dbb378cb90/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win32.whl", hash = "sha256:f4082a00b834c1e34b961e0612f3e581356bdb38c5798bd6842f88ec02e5152b", size = 105535 }, + { url = "https://files.pythonhosted.org/packages/b6/d5/5761a8b6dcc56957018970dd443059c8ee8a79de7b07f0b4d143f8e7dc15/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:44277a3f2cc5ac32ce9b4b2d96c5c5f601d394ac5f02cc71bcd551f738660e2d", size = 114612 }, + { url = "https://files.pythonhosted.org/packages/24/0b/7819bb102286752d3572a75d03e6a8000ffe3c6cb7aee3eb136dca383fe2/winrt_windows_devices_bluetooth-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:0803a417403a7d225316b9b0c4fe3f8446579d6a22f2f729a2c21f4befc74a80", size = 105017 }, + { url = "https://files.pythonhosted.org/packages/54/ff/c4a3de909a875b46fad5e9f4fd412bba48571405bfa802b878954abf128c/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win32.whl", hash = "sha256:18c833ec49e7076127463679e85efc59f61785ade0dc185c852586b21be1f31c", size = 105752 }, + { url = "https://files.pythonhosted.org/packages/e7/78/bfee1f0c8d188c561c5b946ab21f6a0037e60dea110e80b1d6a1d529639f/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:9b6702c462b216c91e32388023a74d0f87210cef6fd5d93b7191e9427ce2faca", size = 113356 }, + { url = "https://files.pythonhosted.org/packages/d2/1b/d9da9c29d36cabadef4e19c3e9ba6d2692f6a28224c81fcff757132ea0da/winrt_windows_devices_bluetooth-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:419fd1078c7749119f6b4bbf6be4e586e03a0ed544c03b83178f1d85f1b3d148", size = 104724 }, + { url = "https://files.pythonhosted.org/packages/d4/cc/797516c5c0f8d7f5b680862e0ed7c1087c58aec0bcf57a417fa90f7eb983/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win32.whl", hash = "sha256:12b0a16fb36ce0b42243ca81f22a6b53fbb344ed7ea07a6eeec294604f0505e4", size = 105757 }, + { url = "https://files.pythonhosted.org/packages/05/6d/f60588846a065e69a2ec5e67c5f85eb45cb7edef2ee8974cd52fa8504de6/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6703dfbe444ee22426738830fb305c96a728ea9ccce905acfdf811d81045fdb3", size = 113363 }, + { url = "https://files.pythonhosted.org/packages/2c/13/2d3c4762018b26a9f66879676ea15d7551cdbf339c8e8e0c56ea05ea31ef/winrt_windows_devices_bluetooth-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2cf8a0bfc9103e32dc7237af15f84be06c791f37711984abdca761f6318bbdb2", size = 104722 }, + { url = "https://files.pythonhosted.org/packages/91/b1/981062e842b69b2b21ca67d8067553432730f1ca2dcb54ec2a658ba1f7b2/winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win32.whl", hash = "sha256:32fc355bfdc5d6b3b1875df16eaf12f9b9fc0445e01177833c27d9a4fc0d50b6", size = 105771 }, + { url = "https://files.pythonhosted.org/packages/4d/6f/ca29e2d8c718c9561e04838cce337baaada36b4895fdff4738b92529b244/winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b886ef1fc0ed49163ae6c2422dd5cb8dd4709da7972af26c8627e211872818d0", size = 114988 }, + { url = "https://files.pythonhosted.org/packages/ad/06/a3bf54a487ab706bd5d0e05eeeab9256f46e0484edb2799fa7e37b3e0ba1/winrt_windows_devices_bluetooth-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8643afa53f9fb8fe3b05967227f86f0c8e1d7b822289e60a848c6368acc977d2", size = 105156 }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-advertisement" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b9/c2b0d201b8b38895809591d089a5edc37e702a23f3a6bc6e542c5e7d6dbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win32.whl", hash = "sha256:a758c5f81a98cc38347fdfb024ce62720969480e8c5b98e402b89d2b09b32866", size = 89730 }, + { url = "https://files.pythonhosted.org/packages/56/f9/f086c3ac17745a71d8384e1831cab0d5a7c737e1fe5cb84d7584f6c14bbf/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:f982ef72e729ddd60cdb975293866e84bb838798828933012a57ee4bf12b0ea1", size = 95825 }, + { url = "https://files.pythonhosted.org/packages/aa/b5/f7f830b2da1fb7ffcaf25ce2734db0019615111f8f39e7b4d83fea4a0bd0/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:e88a72e1e09c7ccc899a9e6d2ab3fc0f43b5dd4509bcc49ec4abf65b55ab015f", size = 89402 }, + { url = "https://files.pythonhosted.org/packages/ad/5e/c628719e877a89f00cac7ce53f9666acbc5ed6f074130729d5d6768b63ff/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win32.whl", hash = "sha256:fe17c2cf63284646622e8b2742b064bf7970bbf53cfab02062136c67fa6b06c9", size = 89614 }, + { url = "https://files.pythonhosted.org/packages/ac/1a/d172d6f1c2fae53535e7f23835025cf39e3002749a0304f18a38e8ed490d/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:78e99dd48b4d89b71b7778c5085fdba64e754dd3ebc54fd09c200fe5222c6e09", size = 95783 }, + { url = "https://files.pythonhosted.org/packages/67/c1/568dfdaea62ca3b13bb70162cb292e5cd0be5bbb98b738961ddcc2edd374/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6d5d2295474deab444fc4311580c725a2ca8a814b0f3344d0779828891d75401", size = 89253 }, + { url = "https://files.pythonhosted.org/packages/c9/15/ad05c28e049208c97011728e2debdb45439175f75efe357b6faa4c9ba099/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win32.whl", hash = "sha256:901933cc40de5eb7e5f4188897c899dd0b0f577cb2c13eab1a63c7dfe89b08c4", size = 90033 }, + { url = "https://files.pythonhosted.org/packages/26/48/074779081841f6eba4987930c4e7adcec38a5985b7dffd9fecc41f39a89c/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e6c66e7d4f4ca86d2c801d30efd2b9673247b59a2b4c365d9e11650303d68d89", size = 95824 }, + { url = "https://files.pythonhosted.org/packages/aa/25/e01966033a02b2d0718710bb47ef4f6b9b5a619ca2c857e06eb5c8e3ed13/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:447d19defd8982d39944642eb7ebe89e4e20259ec9734116cf88879fb2c514ff", size = 89311 }, + { url = "https://files.pythonhosted.org/packages/34/01/8fc8e57605ea08dd0723c035ed0c2d0435dace2bc80a66d33aecfea49a56/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4122348ea525a914e85615647a0b54ae8b2f42f92cdbf89c5a12eea53ef6ed90", size = 90037 }, + { url = "https://files.pythonhosted.org/packages/86/83/503cf815d84c5ba8c8bc61480f32e55579ebf76630163405f7df39aa297b/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b66410c04b8dae634a7e4b615c3b7f8adda9c7d4d6902bcad5b253da1a684943", size = 95822 }, + { url = "https://files.pythonhosted.org/packages/32/13/052be8b6642e6f509b30c194312b37bfee8b6b60ac3bd5ca2968c3ea5b80/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:07af19b1d252ddb9dd3eb2965118bc2b7cabff4dda6e499341b765e5038ca61d", size = 89326 }, + { url = "https://files.pythonhosted.org/packages/7c/12/ea0841207e6fc0cfbbfb54415930d30ad6dba77bc5a7cdbba20ba75ac1b7/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win32.whl", hash = "sha256:6c4747d2e5b0e2ef24e9b84a848cf8fc50fb5b268a2086b5ee8680206d1e0197", size = 89718 }, + { url = "https://files.pythonhosted.org/packages/67/1d/77b69e74c1dd1a6e0dabc91150314abb56414fed265314aafde78cb01b91/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:18d4c5d8b80ee2d29cc13c2fc1353fdb3c0f620c8083701c9b9ecf5e6c503c8d", size = 96212 }, + { url = "https://files.pythonhosted.org/packages/9f/b6/0fd1d10358521b824c38409f81fe81a41f3ed4bd9d14a253d912c69d7d4a/winrt_windows_devices_bluetooth_advertisement-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:75dd856611d847299078d56aee60e319df52975b931c992cd1d32ad5143fe772", size = 89572 }, +] + +[[package]] +name = "winrt-windows-devices-bluetooth-genericattributeprofile" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/a3/449ffc2f8e4c3cfbe7f14c1b43bcaa0475fbd2e8e8bf08465399c5ea078c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win32.whl", hash = "sha256:af4914d7b30b49232092cd3b934e3ed6f5d3b1715ba47238541408ee595b7f46", size = 182059 }, + { url = "https://files.pythonhosted.org/packages/50/d9/6ea88731df569f5c1b086daf4c3496c8d43281588e3a578ea623fef6bc43/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e557dd52fc80392b8bd7c237e1153a50a164b3983838b4ac674551072efc9ed", size = 187866 }, + { url = "https://files.pythonhosted.org/packages/e9/2c/ace56fd32ad07608462de0ac7df218e0bf810e4cc31f2c0fbd7f5f90ee93/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:64cff62baa6b7aadd6c206e61d149113fdcda17360feb6e9d05bc8bbda4b9fde", size = 184627 }, + { url = "https://files.pythonhosted.org/packages/fa/5e/349a5d958be8c0570f0a49bbb746088bcfaa81555accb57503ba01185359/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win32.whl", hash = "sha256:832cf65d035a11e6dbfef4fd66abdcc46be7e911ec96e2e72e98e12d8d5b9d3c", size = 182312 }, + { url = "https://files.pythonhosted.org/packages/90/db/929ab0085ec89e46bd3a58c74b451dd770c3285dfa0cbd4f4aa4730da004/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8179638a6c721b0bbf04ba251ef98d5e02d9a17f0cce377398e42c4fbb441415", size = 187768 }, + { url = "https://files.pythonhosted.org/packages/a3/53/f316e2224c384178204430439f04f9b72017fe8237e341a9aebb20da8191/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:70b7edfca3190b89ae38bf60972b11978311b6d933d3142ae45560c955dbf5c7", size = 184189 }, + { url = "https://files.pythonhosted.org/packages/9c/a1/75ac783a5faee9b455fef2f53b7fef97b21ed60d52401b44c690202141e4/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win32.whl", hash = "sha256:ef894d21e0a805f3e114940254636a8045335fa9de766c7022af5d127dfad557", size = 183326 }, + { url = "https://files.pythonhosted.org/packages/7a/d9/a9dcc15322d2f5c7dfd491bd7ab121e36437caf78ebfa92bc0dd0546e2ca/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:db05de95cd1b24a51abb69cb936a8b17e9214e015757d0b37e3a5e207ddceb3d", size = 187810 }, + { url = "https://files.pythonhosted.org/packages/d2/fc/47d00af076f558267097af3050910beda6bf8a21ceaa5830bbd26fcaf85e/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d4e131cf3d15fc5ad81c1bcde3509ac171298217381abed6bdf687f29871984", size = 184516 }, + { url = "https://files.pythonhosted.org/packages/ec/93/30b45ce473d1a604908221a1fa035fe8d5e4bb9008e820ae671a21dab94c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win32.whl", hash = "sha256:b1879c8dcf46bd2110b9ad4b0b185f4e2a5f95170d014539203a5fee2b2115f0", size = 183342 }, + { url = "https://files.pythonhosted.org/packages/5b/3b/eb9d99b82a36002d7885206d00ea34f4a23db69c16c94816434ded728fa3/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d8d89f01e9b6931fb48217847caac3227a0aeb38a5b7782af71c2e7b262ec30", size = 187844 }, + { url = "https://files.pythonhosted.org/packages/84/9b/ebbbe9be9a3e640dcfc5f166eb48f2f9d8ce42553f83aa9f4c5dcd9eb5f5/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:4e71207bb89798016b1795bb15daf78afe45529f2939b3b9e78894cfe650b383", size = 184540 }, + { url = "https://files.pythonhosted.org/packages/f2/7b/363ba98d862ca84ca4b477cabf74cae0c13c0d6e219f9365dd772f25ab15/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win32.whl", hash = "sha256:963339a0161f9970b577a6193924be783978d11693da48b41a025f61b3c5562a", size = 182901 }, + { url = "https://files.pythonhosted.org/packages/d3/95/508ef65a093460ee74a78da1ec1fa482c0838f6b38bdd84c180e65976b9b/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d43615c5dfa939dd30fe80dc0649434a13cc7cf0294ad0d7283d5a9f48c6ce86", size = 188634 }, + { url = "https://files.pythonhosted.org/packages/43/cc/e22fa06423b646ea9ea474e1e27788be78f1bf37c2f9616dce343722523c/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:8e70fa970997e2e67a8a4172bc00b0b2a79b5ff5bb2668f79cf10b3fd63d3974", size = 185079 }, +] + +[[package]] +name = "winrt-windows-devices-enumeration" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/61/2744d0e0b3fa7807149a1a36dd89abba901d6b24184d9fd5ef3f28467232/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win32.whl", hash = "sha256:40dac777d8f45b41449f3ff1ae70f0d457f1ede53f53962a6e2521b651533db5", size = 130040 }, + { url = "https://files.pythonhosted.org/packages/7a/f9/881b7ee8acdf3c9fe6c79d8ccd90f9246b397fc78420d55014c4ac05b822/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a101ec3e0ad0a0783032fdcd5dc48e7cd68ee034cbde4f903a8c7b391532c71a", size = 142463 }, + { url = "https://files.pythonhosted.org/packages/12/db/b09dffcf1158b35d81d8d57bf19ad04293870cea5afa77943c87f1110d88/winrt_windows_devices_enumeration-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:3296a3863ac086928ff3f3dc872b2a2fb971dab728817424264f3ca547504e9e", size = 135871 }, + { url = "https://files.pythonhosted.org/packages/a6/92/ca1fd311d96fce15fba25543a2ae3cb829744a8af548a11d74233d0e4f64/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9f29465a6c6b0456e4330d4ad09eccdd53a17e1e97695c2e57db0d4666cc0011", size = 129898 }, + { url = "https://files.pythonhosted.org/packages/03/fd/5bd5da5d7997725ba3f1995c16aa1c3362937f8ff68ad4cadfd3415eebcb/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2a725d04b4cb43aa0e2af035f73a60d16a6c0ff165fcb6b763383e4e33a975fd", size = 142361 }, + { url = "https://files.pythonhosted.org/packages/df/be/d423b63e740600e0617ddb85fba3ef99e7bbff02299fe46323bfe624a382/winrt_windows_devices_enumeration-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6365ef5978d4add26678827286034acf474b6b133aa4054e76567d12194e6817", size = 135808 }, + { url = "https://files.pythonhosted.org/packages/31/3e/81642208ecd6c6c936f35a39a433c54e3f68e09d316546b8f953581ae334/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win32.whl", hash = "sha256:1db22b0292b93b0688d11ad932ad1f3629d4f471310281a2fbfe187530c2c1f3", size = 130249 }, + { url = "https://files.pythonhosted.org/packages/00/f4/a9ede5f3f0d86abfc7590726cf711133d97419b49ced372fca532e4f0696/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:a73bc88d7f510af454f2b392985501c96f39b89fd987140708ccaec1588ceebc", size = 141512 }, + { url = "https://files.pythonhosted.org/packages/31/ef/4fad07c03124bdc3acd64f80f3bd3cc4417ea641e07bb16a9503afd3e554/winrt_windows_devices_enumeration-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:2853d687803f0dd76ae1afe3648abc0453e09dff0e7eddbb84b792eddb0473ca", size = 135383 }, + { url = "https://files.pythonhosted.org/packages/ff/7d/ebd712ab8ccd599c593796fbcd606abe22b5a8e20db134aa87987d67ac0e/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win32.whl", hash = "sha256:14a71cdcc84f624c209cbb846ed6bd9767a9a9437b2bf26b48ac9a91599da6e9", size = 130276 }, + { url = "https://files.pythonhosted.org/packages/70/de/f30daaaa0e6f4edb6bd7ddb3e058bd453c9ad90c032a4545c4d4639338aa/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6ca40d334734829e178ad46375275c4f7b5d6d2d4fc2e8879690452cbfb36015", size = 141536 }, + { url = "https://files.pythonhosted.org/packages/75/4b/9a6aafdc74a085c550641a325be463bf4b811f6f605766c9cd4f4b5c19d2/winrt_windows_devices_enumeration-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2d14d187f43e4409c7814b7d1693c03a270e77489b710d92fcbbaeca5de260d4", size = 135362 }, + { url = "https://files.pythonhosted.org/packages/56/ab/693f3d85eed14027aafd1f5e50c7f85cd0fce8425842735528f8b9a0b99e/winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win32.whl", hash = "sha256:986e8d651b769a0e60d2834834bdd3f6959f6a88caa0c9acb917797e6b43a588", size = 130257 }, + { url = "https://files.pythonhosted.org/packages/5a/b2/40d976e3c1252bb2c7893168ccc7b9ac07ef11a109f14d61bf6b687537e3/winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:10da7d403ac4afd385fe13bd5808c9a5dd616a8ef31ca5c64cea3f87673661c1", size = 143193 }, + { url = "https://files.pythonhosted.org/packages/58/b5/76fb16c106e22dd6e39b9e1c4952c71097fa50799aacd7eb9f15de2802e2/winrt_windows_devices_enumeration-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:679e471d21ac22cb50de1bf4dfc4c0c3f5da9f3e3fbc7f08dcacfe9de9d6dd58", size = 136098 }, +] + +[[package]] +name = "winrt-windows-foundation" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/d387332c4378b41f87211b7dc40a4cfc6b7047dc227448aaa207624fc911/winrt_windows_foundation-3.2.1-cp310-cp310-win32.whl", hash = "sha256:677e98165dcbbf7a2367f905bc61090ef2c568b6e465f87cf7276df4734f3b0b", size = 111969 }, + { url = "https://files.pythonhosted.org/packages/52/71/046c1e2424627c3db66d764871186de4d26936e8a138d6bf04dc143e4606/winrt_windows_foundation-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8f27b4f0fdb73ccc4a3e24bc8010a6607b2bdd722fa799eafce7daa87d19d39", size = 118695 }, + { url = "https://files.pythonhosted.org/packages/e0/2e/2463bc4ad984836fb3ecf1abac62df67bc5cabab004cad09b828b86ed51b/winrt_windows_foundation-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:d900c6165fab4ea589811efa2feed27b532e1b6f505f63bf63e2052b8cb6bdc4", size = 109690 }, + { url = "https://files.pythonhosted.org/packages/c0/36/09b9757f7cbf269e67008ea2ad188a44f974c94c9b49ebf0b52d1a8c4069/winrt_windows_foundation-3.2.1-cp311-cp311-win32.whl", hash = "sha256:d1b5970241ccd61428f7330d099be75f4f52f25e510d82c84dbbdaadd625e437", size = 111944 }, + { url = "https://files.pythonhosted.org/packages/05/a5/216d66df6bdcee58eb3877fabc1544337e23f850bf9f93838db7f5698371/winrt_windows_foundation-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:f3762be2f6e0f2aedf83a0742fd727290b397ffe3463d963d29211e4ebb53a7e", size = 118465 }, + { url = "https://files.pythonhosted.org/packages/be/ca/48ca8b5bc5be5c7a5516c9e1d9a21861b4217e1b4ee57923aab6f13fa411/winrt_windows_foundation-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:806c77818217b3476e6c617293b3d5b0ff8a9901549dc3417586f6799938d671", size = 109609 }, + { url = "https://files.pythonhosted.org/packages/f3/f8/495e304ddedd5ff2f196efbde906265cb75ade4d79e2937837f72ef654a0/winrt_windows_foundation-3.2.1-cp312-cp312-win32.whl", hash = "sha256:867642ccf629611733db482c4288e17b7919f743a5873450efb6d69ae09fdc2b", size = 112169 }, + { url = "https://files.pythonhosted.org/packages/9b/5e/b5059e4ece095351c496c9499783130c302d25e353c18031d5231b1b3b3c/winrt_windows_foundation-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:45550c5b6c2125cde495c409633e6b1ea5aa1677724e3b95eb8140bfccbe30c9", size = 118668 }, + { url = "https://files.pythonhosted.org/packages/a5/70/acbcb3ef07b1b67e2de4afab9176a5282cfd775afd073efe6828dfc65ace/winrt_windows_foundation-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:94f4661d71cb35ebc52be7af112f2eeabdfa02cb05e0243bf9d6bd2cafaa6f37", size = 109671 }, + { url = "https://files.pythonhosted.org/packages/7b/71/5e87131e4aecc8546c76b9e190bfe4e1292d028bda3f9dd03b005d19c76c/winrt_windows_foundation-3.2.1-cp313-cp313-win32.whl", hash = "sha256:3998dc58ed50ecbdbabace1cdef3a12920b725e32a5806d648ad3f4829d5ba46", size = 112184 }, + { url = "https://files.pythonhosted.org/packages/ba/7f/8d5108461351d4f6017f550af8874e90c14007f9122fa2eab9f9e0e9b4e1/winrt_windows_foundation-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:6e98617c1e46665c7a56ce3f5d28e252798416d1ebfee3201267a644a4e3c479", size = 118672 }, + { url = "https://files.pythonhosted.org/packages/44/f5/2edf70922a3d03500dab17121b90d368979bd30016f6dbca0d043f0c71f1/winrt_windows_foundation-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:2a8c1204db5c352f6a563130a5a41d25b887aff7897bb677d4ff0b660315aad4", size = 109673 }, + { url = "https://files.pythonhosted.org/packages/ac/d0/87ed35b78f143f282fb65775fa8845106ec8bbfdad7fca1ae8a7f8740064/winrt_windows_foundation-3.2.1-cp39-cp39-win32.whl", hash = "sha256:14d5191725301498e4feb744d91f5b46ce317bf3d28370efda407d5c87f4423b", size = 112280 }, + { url = "https://files.pythonhosted.org/packages/19/71/617c10dee1d6c6541ea4215229aa4aad0373fffa565ce788790b90369d41/winrt_windows_foundation-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:de5e4f61d253a91ba05019dbf4338c43f962bdad935721ced5e7997933994af5", size = 119479 }, + { url = "https://files.pythonhosted.org/packages/1f/2c/235a54e2746a868f6fc9f563b03d95c5974038e3d37bccd5b541442c0450/winrt_windows_foundation-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:ebbf6e8168398c9ed0c72c8bdde95a406b9fbb9a23e3705d4f0fe28e5a209705", size = 110247 }, +] + +[[package]] +name = "winrt-windows-foundation-collections" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/26/ed3d35ea262999d28be957c35a32e93360eac0ef9f14e75d32cd6b5c6a37/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win32.whl", hash = "sha256:46948484addfc4db981dab35688d4457533ceb54d4954922af41503fddaa8389", size = 59880 }, + { url = "https://files.pythonhosted.org/packages/cb/39/b4a1aeba2d13c1f2ad3d851d5092b8397c05f34fb318d6a7d499f5b5720b/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:899eaa3a93c35bfb1857d649e8dd60c38b978dda7cedd9725fcdbcebba156fd6", size = 70650 }, + { url = "https://files.pythonhosted.org/packages/9f/74/f8a4a29202da24f2af2c4a8f515b0a44fe46bc4d25b3d54ea2249e980bd3/winrt_windows_foundation_collections-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:c36eb49ad1eba1b32134df768bb47af13cabb9b59f974a3cea37843e2d80e0e6", size = 59216 }, + { url = "https://files.pythonhosted.org/packages/87/b3/7e4a75c62e86bedf9458b7ec8dfed74cff3236e0b4b2288f95967d5cc4d2/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win32.whl", hash = "sha256:9b272d9936e7db4840881c5dcf921eb26789ae4ef23fb6ec15e13e19a16254e7", size = 59693 }, + { url = "https://files.pythonhosted.org/packages/32/58/049db1d95fdfc0c8451dc6db17442ed4e6b2aba361c425c0bb8dc8c98c4a/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:c646a5d442dd6540ade50890081ca118b41f073356e19032d0a5d7d0d38fbc89", size = 70828 }, + { url = "https://files.pythonhosted.org/packages/5b/6b/a04974f5555c86452e54c19d063d9fd45f0fe9f2a6858e7fe12c639043fb/winrt_windows_foundation_collections-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2c4630027c93cdd518b0cf4cc726b8fbdbc3388e36d02aa1de190a0fc18ca523", size = 59051 }, + { url = "https://files.pythonhosted.org/packages/1d/0b/7802349391466d3f7e8f62f588f36a1a0b6560abfcdbdaa426fe21d322b4/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win32.whl", hash = "sha256:15704eef3125788f846f269cf54a3d89656fa09a1dc8428b70871f717d595ad6", size = 60060 }, + { url = "https://files.pythonhosted.org/packages/37/94/5b888713e472746635a382e523513ab1b8200af55c5b56bc70e1e4369115/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:550dfb8c82fe74d9e0728a2a16a9175cc9e34ca2b8ef758d69b2a398894b698b", size = 69058 }, + { url = "https://files.pythonhosted.org/packages/5f/3c/829273622c9b37c67b97f187b92be318404f7d33db045e31d72b7d50f54c/winrt_windows_foundation_collections-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:810ad4bd11ab4a74fdbcd3ed33b597ef7c0b03af73fc9d7986c22bcf3bd24f84", size = 58793 }, + { url = "https://files.pythonhosted.org/packages/a6/cd/99ef050d80bea2922fa1ded93e5c250732634095d8bd3595dd808083e5ca/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win32.whl", hash = "sha256:4267a711b63476d36d39227883aeb3fb19ac92b88a9fc9973e66fbce1fd4aed9", size = 60063 }, + { url = "https://files.pythonhosted.org/packages/94/93/4f75fd6a4c96f1e9bee198c5dc9a9b57e87a9c38117e1b5e423401886353/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:5e12a6e75036ee90484c33e204b85fb6785fcc9e7c8066ad65097301f48cdd10", size = 69057 }, + { url = "https://files.pythonhosted.org/packages/40/76/de47ccc390017ec5575e7e7fd9f659ee3747c52049cdb2969b1b538ce947/winrt_windows_foundation_collections-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:34b556255562f1b36d07fba933c2bcd9f0db167fa96727a6cbb4717b152ad7a2", size = 58792 }, + { url = "https://files.pythonhosted.org/packages/e6/3c/69cd1a35df8120556b96e15b3c2cb844b227939bb286a4828aa3419f713b/winrt_windows_foundation_collections-3.2.1-cp39-cp39-win32.whl", hash = "sha256:20610f098b84c87765018cbc71471092197881f3b92e5d06158fad3bfcea2563", size = 60260 }, + { url = "https://files.pythonhosted.org/packages/1b/fb/a044f0b377a21491a8211ab675213672494b290fdc155f443f07ad900f91/winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:e9739775320ac4c0238e1775d94a54e886d621f9995977e65d4feb8b3778c111", size = 71293 }, + { url = "https://files.pythonhosted.org/packages/0f/02/ed1096fef5d2663715ef150a2af79cfcf52ae86d10dd362440a832b77059/winrt_windows_foundation_collections-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:e4c6bddb1359d5014ceb45fe2ecd838d4afeb1184f2ea202c2d21037af0d08a3", size = 59509 }, +] + +[[package]] +name = "winrt-windows-storage-streams" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "winrt-runtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/4d/a0d806f4664b9bcf525bd31dcdf1f9520cc14f033e897dc7f7dd4ad4eb77/winrt_windows_storage_streams-3.2.1-cp310-cp310-win32.whl", hash = "sha256:89bb2d667ebed6861af36ed2710757456e12921ee56347946540320dacf6c003", size = 127791 }, + { url = "https://files.pythonhosted.org/packages/99/2c/00baa87041a3d92a3cc5230d4033e995a52740e9c08fcd9f7bde93cb979f/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:48a78e5dc7d3488eb77e449c278bc6d6ac28abcdda7df298462c4112d7635d00", size = 132608 }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ed03e864aa8eaaec964d5bbc95baccf738275ae6cc88600db66ecb5adaf4/winrt_windows_storage_streams-3.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:da71231d4a554f9f15f1249b4990c6431176f6dfb0e3385c7caa7896f4ca24d6", size = 128495 }, + { url = "https://files.pythonhosted.org/packages/19/60/a9e0dc03434aa29e6b5c83067e988cd5934adf830cd9f87cbbc06569ca32/winrt_windows_storage_streams-3.2.1-cp311-cp311-win32.whl", hash = "sha256:7dace2f9e364422255d0e2f335f741bfe7abb1f4d4f6003622b2450b87c91e69", size = 127509 }, + { url = "https://files.pythonhosted.org/packages/23/98/6c9c21b5e75ff5927a130da9eaf5ab628dfa1f93b64c181f0193706cbd6c/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:b02fa251a7eef6081eca1a5f64ecf349cfd1ac0ac0c5a5a30be52897d060bed5", size = 132491 }, + { url = "https://files.pythonhosted.org/packages/38/ca/d0a02045d445cbf1029d65f01b487fdded5b333c0367a8bae0565b3def00/winrt_windows_storage_streams-3.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:efdf250140340a75647e8e8ad002782d91308e9fdd1e19470a5b9cc969ae4780", size = 128577 }, + { url = "https://files.pythonhosted.org/packages/87/e7/7d3f2a4a442f264e05cab2bdf20ed1b95cb3f753bd1b0f277f2b49fb8335/winrt_windows_storage_streams-3.2.1-cp312-cp312-win32.whl", hash = "sha256:77c1f0e004b84347b5bd705e8f0fc63be8cd29a6093be13f1d0869d0d97b7d78", size = 127787 }, + { url = "https://files.pythonhosted.org/packages/c6/2f/cc36f475f8af293f40e2c2a5d6c2e75a189c2c2d4d01ecb3551578518c79/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:e4508ee135af53e4fc142876abbf4bc7c2a95edfc7d19f52b291a8499cacd6dc", size = 131849 }, + { url = "https://files.pythonhosted.org/packages/94/84/896fb734f7456910ec412f3f3adfdc3f0dc3134864a496d5b120592f3bfd/winrt_windows_storage_streams-3.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:040cb94e6fb26b0d00a00e8b88b06fadf29dfe18cf24ed6cb3e69709c3613307", size = 128144 }, + { url = "https://files.pythonhosted.org/packages/d9/d2/24d9f59bdc05e741261d5bec3bcea9a848d57714126a263df840e2b515a8/winrt_windows_storage_streams-3.2.1-cp313-cp313-win32.whl", hash = "sha256:401bb44371720dc43bd1e78662615a2124372e7d5d9d65dfa8f77877bbcb8163", size = 127774 }, + { url = "https://files.pythonhosted.org/packages/15/59/601724453b885265c7779d5f8025b043a68447cbc64ceb9149d674d5b724/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:202c5875606398b8bfaa2a290831458bb55f2196a39c1d4e5fa88a03d65ef915", size = 131827 }, + { url = "https://files.pythonhosted.org/packages/fb/c2/a419675a6087c9ea496968c9b7805ef234afa585b7483e2269608a12b044/winrt_windows_storage_streams-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ca3c5ec0aab60895006bf61053a1aca6418bc7f9a27a34791ba3443b789d230d", size = 128180 }, + { url = "https://files.pythonhosted.org/packages/7a/74/5313b13e0c390c9066bb0f78d1e92b78f588ef123cd7da4ceae6d887ca9e/winrt_windows_storage_streams-3.2.1-cp39-cp39-win32.whl", hash = "sha256:1c630cfdece58fcf82e4ed86c826326123529836d6d4d855ae8e9ceeff67b627", size = 128256 }, + { url = "https://files.pythonhosted.org/packages/1e/87/edf73f0fb9ec342942dd7867682c4a85a6ecc25eeb212aef0d64f91667aa/winrt_windows_storage_streams-3.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:d7ff22434a4829d616a04b068a191ac79e008f6c27541bb178c1f6f1fe7a1657", size = 133724 }, + { url = "https://files.pythonhosted.org/packages/9e/13/cc2cda5a998efb894e90f96b8e1320098924ed331540122049ddab31d5c8/winrt_windows_storage_streams-3.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:fa90244191108f85f6f7afb43a11d365aca4e0722fe8adc62fb4d2c678d0993d", size = 128967 }, +] + +[[package]] +name = "xxhash" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/5e/d6e5258d69df8b4ed8c83b6664f2b47d30d2dec551a29ad72a6c69eafd31/xxhash-3.5.0.tar.gz", hash = "sha256:84f2caddf951c9cbf8dc2e22a89d4ccf5d86391ac6418fe81e3c67d0cf60b45f", size = 84241 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/8a/0e9feca390d512d293afd844d31670e25608c4a901e10202aa98785eab09/xxhash-3.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ece616532c499ee9afbb83078b1b952beffef121d989841f7f4b3dc5ac0fd212", size = 31970 }, + { url = "https://files.pythonhosted.org/packages/16/e6/be5aa49580cd064a18200ab78e29b88b1127e1a8c7955eb8ecf81f2626eb/xxhash-3.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3171f693dbc2cef6477054a665dc255d996646b4023fe56cb4db80e26f4cc520", size = 30801 }, + { url = "https://files.pythonhosted.org/packages/20/ee/b8a99ebbc6d1113b3a3f09e747fa318c3cde5b04bd9c197688fadf0eeae8/xxhash-3.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c5d3e570ef46adaf93fc81b44aca6002b5a4d8ca11bd0580c07eac537f36680", size = 220927 }, + { url = "https://files.pythonhosted.org/packages/58/62/15d10582ef159283a5c2b47f6d799fc3303fe3911d5bb0bcc820e1ef7ff4/xxhash-3.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cb29a034301e2982df8b1fe6328a84f4b676106a13e9135a0d7e0c3e9f806da", size = 200360 }, + { url = "https://files.pythonhosted.org/packages/23/41/61202663ea9b1bd8e53673b8ec9e2619989353dba8cfb68e59a9cbd9ffe3/xxhash-3.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0d307d27099bb0cbeea7260eb39ed4fdb99c5542e21e94bb6fd29e49c57a23", size = 428528 }, + { url = "https://files.pythonhosted.org/packages/f2/07/d9a3059f702dec5b3b703737afb6dda32f304f6e9da181a229dafd052c29/xxhash-3.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0342aafd421795d740e514bc9858ebddfc705a75a8c5046ac56d85fe97bf196", size = 194149 }, + { url = "https://files.pythonhosted.org/packages/eb/58/27caadf78226ecf1d62dbd0c01d152ed381c14c1ee4ad01f0d460fc40eac/xxhash-3.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dbbd9892c5ebffeca1ed620cf0ade13eb55a0d8c84e0751a6653adc6ac40d0c", size = 207703 }, + { url = "https://files.pythonhosted.org/packages/b1/08/32d558ce23e1e068453c39aed7b3c1cdc690c177873ec0ca3a90d5808765/xxhash-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4cc2d67fdb4d057730c75a64c5923abfa17775ae234a71b0200346bfb0a7f482", size = 216255 }, + { url = "https://files.pythonhosted.org/packages/3f/d4/2b971e2d2b0a61045f842b622ef11e94096cf1f12cd448b6fd426e80e0e2/xxhash-3.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ec28adb204b759306a3d64358a5e5c07d7b1dd0ccbce04aa76cb9377b7b70296", size = 202744 }, + { url = "https://files.pythonhosted.org/packages/19/ae/6a6438864a8c4c39915d7b65effd85392ebe22710412902487e51769146d/xxhash-3.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1328f6d8cca2b86acb14104e381225a3d7b42c92c4b86ceae814e5c400dbb415", size = 210115 }, + { url = "https://files.pythonhosted.org/packages/48/7d/b3c27c27d1fc868094d02fe4498ccce8cec9fcc591825c01d6bcb0b4fc49/xxhash-3.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8d47ebd9f5d9607fd039c1fbf4994e3b071ea23eff42f4ecef246ab2b7334198", size = 414247 }, + { url = "https://files.pythonhosted.org/packages/a1/05/918f9e7d2fbbd334b829997045d341d6239b563c44e683b9a7ef8fe50f5d/xxhash-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b96d559e0fcddd3343c510a0fe2b127fbff16bf346dd76280b82292567523442", size = 191419 }, + { url = "https://files.pythonhosted.org/packages/08/29/dfe393805b2f86bfc47c290b275f0b7c189dc2f4e136fd4754f32eb18a8d/xxhash-3.5.0-cp310-cp310-win32.whl", hash = "sha256:61c722ed8d49ac9bc26c7071eeaa1f6ff24053d553146d5df031802deffd03da", size = 30114 }, + { url = "https://files.pythonhosted.org/packages/7b/d7/aa0b22c4ebb7c3ccb993d4c565132abc641cd11164f8952d89eb6a501909/xxhash-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bed5144c6923cc902cd14bb8963f2d5e034def4486ab0bbe1f58f03f042f9a9", size = 30003 }, + { url = "https://files.pythonhosted.org/packages/69/12/f969b81541ee91b55f1ce469d7ab55079593c80d04fd01691b550e535000/xxhash-3.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:893074d651cf25c1cc14e3bea4fceefd67f2921b1bb8e40fcfeba56820de80c6", size = 26773 }, + { url = "https://files.pythonhosted.org/packages/b8/c7/afed0f131fbda960ff15eee7f304fa0eeb2d58770fade99897984852ef23/xxhash-3.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02c2e816896dc6f85922ced60097bcf6f008dedfc5073dcba32f9c8dd786f3c1", size = 31969 }, + { url = "https://files.pythonhosted.org/packages/8c/0c/7c3bc6d87e5235672fcc2fb42fd5ad79fe1033925f71bf549ee068c7d1ca/xxhash-3.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6027dcd885e21581e46d3c7f682cfb2b870942feeed58a21c29583512c3f09f8", size = 30800 }, + { url = "https://files.pythonhosted.org/packages/04/9e/01067981d98069eec1c20201f8c145367698e9056f8bc295346e4ea32dd1/xxhash-3.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1308fa542bbdbf2fa85e9e66b1077eea3a88bef38ee8a06270b4298a7a62a166", size = 221566 }, + { url = "https://files.pythonhosted.org/packages/d4/09/d4996de4059c3ce5342b6e1e6a77c9d6c91acce31f6ed979891872dd162b/xxhash-3.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c28b2fdcee797e1c1961cd3bcd3d545cab22ad202c846235197935e1df2f8ef7", size = 201214 }, + { url = "https://files.pythonhosted.org/packages/62/f5/6d2dc9f8d55a7ce0f5e7bfef916e67536f01b85d32a9fbf137d4cadbee38/xxhash-3.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:924361811732ddad75ff23e90efd9ccfda4f664132feecb90895bade6a1b4623", size = 429433 }, + { url = "https://files.pythonhosted.org/packages/d9/72/9256303f10e41ab004799a4aa74b80b3c5977d6383ae4550548b24bd1971/xxhash-3.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89997aa1c4b6a5b1e5b588979d1da048a3c6f15e55c11d117a56b75c84531f5a", size = 194822 }, + { url = "https://files.pythonhosted.org/packages/34/92/1a3a29acd08248a34b0e6a94f4e0ed9b8379a4ff471f1668e4dce7bdbaa8/xxhash-3.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:685c4f4e8c59837de103344eb1c8a3851f670309eb5c361f746805c5471b8c88", size = 208538 }, + { url = "https://files.pythonhosted.org/packages/53/ad/7fa1a109663366de42f724a1cdb8e796a260dbac45047bce153bc1e18abf/xxhash-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dbd2ecfbfee70bc1a4acb7461fa6af7748ec2ab08ac0fa298f281c51518f982c", size = 216953 }, + { url = "https://files.pythonhosted.org/packages/35/02/137300e24203bf2b2a49b48ce898ecce6fd01789c0fcd9c686c0a002d129/xxhash-3.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25b5a51dc3dfb20a10833c8eee25903fd2e14059e9afcd329c9da20609a307b2", size = 203594 }, + { url = "https://files.pythonhosted.org/packages/23/03/aeceb273933d7eee248c4322b98b8e971f06cc3880e5f7602c94e5578af5/xxhash-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a8fb786fb754ef6ff8c120cb96629fb518f8eb5a61a16aac3a979a9dbd40a084", size = 210971 }, + { url = "https://files.pythonhosted.org/packages/e3/64/ed82ec09489474cbb35c716b189ddc1521d8b3de12b1b5ab41ce7f70253c/xxhash-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a905ad00ad1e1c34fe4e9d7c1d949ab09c6fa90c919860c1534ff479f40fd12d", size = 415050 }, + { url = "https://files.pythonhosted.org/packages/71/43/6db4c02dcb488ad4e03bc86d70506c3d40a384ee73c9b5c93338eb1f3c23/xxhash-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:963be41bcd49f53af6d795f65c0da9b4cc518c0dd9c47145c98f61cb464f4839", size = 192216 }, + { url = "https://files.pythonhosted.org/packages/22/6d/db4abec29e7a567455344433d095fdb39c97db6955bb4a2c432e486b4d28/xxhash-3.5.0-cp311-cp311-win32.whl", hash = "sha256:109b436096d0a2dd039c355fa3414160ec4d843dfecc64a14077332a00aeb7da", size = 30120 }, + { url = "https://files.pythonhosted.org/packages/52/1c/fa3b61c0cf03e1da4767213672efe186b1dfa4fc901a4a694fb184a513d1/xxhash-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:b702f806693201ad6c0a05ddbbe4c8f359626d0b3305f766077d51388a6bac58", size = 30003 }, + { url = "https://files.pythonhosted.org/packages/6b/8e/9e6fc572acf6e1cc7ccb01973c213f895cb8668a9d4c2b58a99350da14b7/xxhash-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:c4dcb4120d0cc3cc448624147dba64e9021b278c63e34a38789b688fd0da9bf3", size = 26777 }, + { url = "https://files.pythonhosted.org/packages/07/0e/1bfce2502c57d7e2e787600b31c83535af83746885aa1a5f153d8c8059d6/xxhash-3.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:14470ace8bd3b5d51318782cd94e6f94431974f16cb3b8dc15d52f3b69df8e00", size = 31969 }, + { url = "https://files.pythonhosted.org/packages/3f/d6/8ca450d6fe5b71ce521b4e5db69622383d039e2b253e9b2f24f93265b52c/xxhash-3.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59aa1203de1cb96dbeab595ded0ad0c0056bb2245ae11fac11c0ceea861382b9", size = 30787 }, + { url = "https://files.pythonhosted.org/packages/5b/84/de7c89bc6ef63d750159086a6ada6416cc4349eab23f76ab870407178b93/xxhash-3.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08424f6648526076e28fae6ea2806c0a7d504b9ef05ae61d196d571e5c879c84", size = 220959 }, + { url = "https://files.pythonhosted.org/packages/fe/86/51258d3e8a8545ff26468c977101964c14d56a8a37f5835bc0082426c672/xxhash-3.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61a1ff00674879725b194695e17f23d3248998b843eb5e933007ca743310f793", size = 200006 }, + { url = "https://files.pythonhosted.org/packages/02/0a/96973bd325412feccf23cf3680fd2246aebf4b789122f938d5557c54a6b2/xxhash-3.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f2c61bee5844d41c3eb015ac652a0229e901074951ae48581d58bfb2ba01be", size = 428326 }, + { url = "https://files.pythonhosted.org/packages/11/a7/81dba5010f7e733de88af9555725146fc133be97ce36533867f4c7e75066/xxhash-3.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d32a592cac88d18cc09a89172e1c32d7f2a6e516c3dfde1b9adb90ab5df54a6", size = 194380 }, + { url = "https://files.pythonhosted.org/packages/fb/7d/f29006ab398a173f4501c0e4977ba288f1c621d878ec217b4ff516810c04/xxhash-3.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70dabf941dede727cca579e8c205e61121afc9b28516752fd65724be1355cc90", size = 207934 }, + { url = "https://files.pythonhosted.org/packages/8a/6e/6e88b8f24612510e73d4d70d9b0c7dff62a2e78451b9f0d042a5462c8d03/xxhash-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e5d0ddaca65ecca9c10dcf01730165fd858533d0be84c75c327487c37a906a27", size = 216301 }, + { url = "https://files.pythonhosted.org/packages/af/51/7862f4fa4b75a25c3b4163c8a873f070532fe5f2d3f9b3fc869c8337a398/xxhash-3.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e5b5e16c5a480fe5f59f56c30abdeba09ffd75da8d13f6b9b6fd224d0b4d0a2", size = 203351 }, + { url = "https://files.pythonhosted.org/packages/22/61/8d6a40f288f791cf79ed5bb113159abf0c81d6efb86e734334f698eb4c59/xxhash-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149b7914451eb154b3dfaa721315117ea1dac2cc55a01bfbd4df7c68c5dd683d", size = 210294 }, + { url = "https://files.pythonhosted.org/packages/17/02/215c4698955762d45a8158117190261b2dbefe9ae7e5b906768c09d8bc74/xxhash-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:eade977f5c96c677035ff39c56ac74d851b1cca7d607ab3d8f23c6b859379cab", size = 414674 }, + { url = "https://files.pythonhosted.org/packages/31/5c/b7a8db8a3237cff3d535261325d95de509f6a8ae439a5a7a4ffcff478189/xxhash-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa9f547bd98f5553d03160967866a71056a60960be00356a15ecc44efb40ba8e", size = 192022 }, + { url = "https://files.pythonhosted.org/packages/78/e3/dd76659b2811b3fd06892a8beb850e1996b63e9235af5a86ea348f053e9e/xxhash-3.5.0-cp312-cp312-win32.whl", hash = "sha256:f7b58d1fd3551b8c80a971199543379be1cee3d0d409e1f6d8b01c1a2eebf1f8", size = 30170 }, + { url = "https://files.pythonhosted.org/packages/d9/6b/1c443fe6cfeb4ad1dcf231cdec96eb94fb43d6498b4469ed8b51f8b59a37/xxhash-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:fa0cafd3a2af231b4e113fba24a65d7922af91aeb23774a8b78228e6cd785e3e", size = 30040 }, + { url = "https://files.pythonhosted.org/packages/0f/eb/04405305f290173acc0350eba6d2f1a794b57925df0398861a20fbafa415/xxhash-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:586886c7e89cb9828bcd8a5686b12e161368e0064d040e225e72607b43858ba2", size = 26796 }, + { url = "https://files.pythonhosted.org/packages/c9/b8/e4b3ad92d249be5c83fa72916c9091b0965cb0faeff05d9a0a3870ae6bff/xxhash-3.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37889a0d13b0b7d739cfc128b1c902f04e32de17b33d74b637ad42f1c55101f6", size = 31795 }, + { url = "https://files.pythonhosted.org/packages/fc/d8/b3627a0aebfbfa4c12a41e22af3742cf08c8ea84f5cc3367b5de2d039cce/xxhash-3.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97a662338797c660178e682f3bc180277b9569a59abfb5925e8620fba00b9fc5", size = 30792 }, + { url = "https://files.pythonhosted.org/packages/c3/cc/762312960691da989c7cd0545cb120ba2a4148741c6ba458aa723c00a3f8/xxhash-3.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f85e0108d51092bdda90672476c7d909c04ada6923c14ff9d913c4f7dc8a3bc", size = 220950 }, + { url = "https://files.pythonhosted.org/packages/fe/e9/cc266f1042c3c13750e86a535496b58beb12bf8c50a915c336136f6168dc/xxhash-3.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2fd827b0ba763ac919440042302315c564fdb797294d86e8cdd4578e3bc7f3", size = 199980 }, + { url = "https://files.pythonhosted.org/packages/bf/85/a836cd0dc5cc20376de26b346858d0ac9656f8f730998ca4324921a010b9/xxhash-3.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82085c2abec437abebf457c1d12fccb30cc8b3774a0814872511f0f0562c768c", size = 428324 }, + { url = "https://files.pythonhosted.org/packages/b4/0e/15c243775342ce840b9ba34aceace06a1148fa1630cd8ca269e3223987f5/xxhash-3.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07fda5de378626e502b42b311b049848c2ef38784d0d67b6f30bb5008642f8eb", size = 194370 }, + { url = "https://files.pythonhosted.org/packages/87/a1/b028bb02636dfdc190da01951d0703b3d904301ed0ef6094d948983bef0e/xxhash-3.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c279f0d2b34ef15f922b77966640ade58b4ccdfef1c4d94b20f2a364617a493f", size = 207911 }, + { url = "https://files.pythonhosted.org/packages/80/d5/73c73b03fc0ac73dacf069fdf6036c9abad82de0a47549e9912c955ab449/xxhash-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:89e66ceed67b213dec5a773e2f7a9e8c58f64daeb38c7859d8815d2c89f39ad7", size = 216352 }, + { url = "https://files.pythonhosted.org/packages/b6/2a/5043dba5ddbe35b4fe6ea0a111280ad9c3d4ba477dd0f2d1fe1129bda9d0/xxhash-3.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bcd51708a633410737111e998ceb3b45d3dbc98c0931f743d9bb0a209033a326", size = 203410 }, + { url = "https://files.pythonhosted.org/packages/a2/b2/9a8ded888b7b190aed75b484eb5c853ddd48aa2896e7b59bbfbce442f0a1/xxhash-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ff2c0a34eae7df88c868be53a8dd56fbdf592109e21d4bfa092a27b0bf4a7bf", size = 210322 }, + { url = "https://files.pythonhosted.org/packages/98/62/440083fafbc917bf3e4b67c2ade621920dd905517e85631c10aac955c1d2/xxhash-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4e28503dccc7d32e0b9817aa0cbfc1f45f563b2c995b7a66c4c8a0d232e840c7", size = 414725 }, + { url = "https://files.pythonhosted.org/packages/75/db/009206f7076ad60a517e016bb0058381d96a007ce3f79fa91d3010f49cc2/xxhash-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6c50017518329ed65a9e4829154626f008916d36295b6a3ba336e2458824c8c", size = 192070 }, + { url = "https://files.pythonhosted.org/packages/1f/6d/c61e0668943a034abc3a569cdc5aeae37d686d9da7e39cf2ed621d533e36/xxhash-3.5.0-cp313-cp313-win32.whl", hash = "sha256:53a068fe70301ec30d868ece566ac90d873e3bb059cf83c32e76012c889b8637", size = 30172 }, + { url = "https://files.pythonhosted.org/packages/96/14/8416dce965f35e3d24722cdf79361ae154fa23e2ab730e5323aa98d7919e/xxhash-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:80babcc30e7a1a484eab952d76a4f4673ff601f54d5142c26826502740e70b43", size = 30041 }, + { url = "https://files.pythonhosted.org/packages/27/ee/518b72faa2073f5aa8e3262408d284892cb79cf2754ba0c3a5870645ef73/xxhash-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:4811336f1ce11cac89dcbd18f3a25c527c16311709a89313c3acaf771def2d4b", size = 26801 }, + { url = "https://files.pythonhosted.org/packages/d4/f6/531dd6858adf8877675270b9d6989b6dacfd1c2d7135b17584fc29866df3/xxhash-3.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bfc8cdd7f33d57f0468b0614ae634cc38ab9202c6957a60e31d285a71ebe0301", size = 31971 }, + { url = "https://files.pythonhosted.org/packages/7c/a8/b2a42b6c9ae46e233f474f3d307c2e7bca8d9817650babeca048d2ad01d6/xxhash-3.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c48b6300cd0b0106bf49169c3e0536408dfbeb1ccb53180068a18b03c662ab", size = 30801 }, + { url = "https://files.pythonhosted.org/packages/b4/92/9ac297e3487818f429bcf369c1c6a097edf5b56ed6fc1feff4c1882e87ef/xxhash-3.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe1a92cfbaa0a1253e339ccec42dbe6db262615e52df591b68726ab10338003f", size = 220644 }, + { url = "https://files.pythonhosted.org/packages/86/48/c1426dd3c86fc4a52f983301867463472f6a9013fb32d15991e60c9919b6/xxhash-3.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:33513d6cc3ed3b559134fb307aae9bdd94d7e7c02907b37896a6c45ff9ce51bd", size = 200021 }, + { url = "https://files.pythonhosted.org/packages/f3/de/0ab8c79993765c94fc0d0c1a22b454483c58a0161e1b562f58b654f47660/xxhash-3.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eefc37f6138f522e771ac6db71a6d4838ec7933939676f3753eafd7d3f4c40bc", size = 428217 }, + { url = "https://files.pythonhosted.org/packages/b4/b4/332647451ed7d2c021294b7c1e9c144dbb5586b1fb214ad4f5a404642835/xxhash-3.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a606c8070ada8aa2a88e181773fa1ef17ba65ce5dd168b9d08038e2a61b33754", size = 193868 }, + { url = "https://files.pythonhosted.org/packages/f4/1c/a42c0a6cac752f84f7b44a90d1a9fa9047cf70bdba5198a304fde7cc471f/xxhash-3.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:42eca420c8fa072cc1dd62597635d140e78e384a79bb4944f825fbef8bfeeef6", size = 207403 }, + { url = "https://files.pythonhosted.org/packages/c4/d7/04e1b0daae9dc9b02c73c1664cc8aa527498c3f66ccbc586eeb25bbe9f14/xxhash-3.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:604253b2143e13218ff1ef0b59ce67f18b8bd1c4205d2ffda22b09b426386898", size = 215978 }, + { url = "https://files.pythonhosted.org/packages/c4/f4/05e15e67505228fc19ee98a79e427b3a0b9695f5567cd66ced5d66389883/xxhash-3.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6e93a5ad22f434d7876665444a97e713a8f60b5b1a3521e8df11b98309bff833", size = 202416 }, + { url = "https://files.pythonhosted.org/packages/94/fb/e9028d3645bba5412a09de13ee36df276a567e60bdb31d499dafa46d76ae/xxhash-3.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7a46e1d6d2817ba8024de44c4fd79913a90e5f7265434cef97026215b7d30df6", size = 209853 }, + { url = "https://files.pythonhosted.org/packages/02/2c/18c6a622429368274739372d2f86c8125413ec169025c7d8ffb051784bba/xxhash-3.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:30eb2efe6503c379b7ab99c81ba4a779748e3830241f032ab46bd182bf5873af", size = 413926 }, + { url = "https://files.pythonhosted.org/packages/72/bb/5b55c391084a0321c3809632a018b9b657e59d5966289664f85a645942ac/xxhash-3.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c8aa771ff2c13dd9cda8166d685d7333d389fae30a4d2bb39d63ab5775de8606", size = 191156 }, + { url = "https://files.pythonhosted.org/packages/86/2b/915049db13401792fec159f57e4f4a5ca7a9768e83ef71d6645b9d0cd749/xxhash-3.5.0-cp39-cp39-win32.whl", hash = "sha256:5ed9ebc46f24cf91034544b26b131241b699edbfc99ec5e7f8f3d02d6eb7fba4", size = 30122 }, + { url = "https://files.pythonhosted.org/packages/d5/87/382ef7b24917d7cf4c540ee30f29b283bc87ac5893d2f89b23ea3cdf7d77/xxhash-3.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:220f3f896c6b8d0316f63f16c077d52c412619e475f9372333474ee15133a558", size = 30021 }, + { url = "https://files.pythonhosted.org/packages/e2/47/d06b24e2d9c3dcabccfd734d11b5bbebfdf59ceac2c61509d8205dd20ac6/xxhash-3.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:a7b1d8315d9b5e9f89eb2933b73afae6ec9597a258d52190944437158b49d38e", size = 26780 }, + { url = "https://files.pythonhosted.org/packages/ab/9a/233606bada5bd6f50b2b72c45de3d9868ad551e83893d2ac86dc7bb8553a/xxhash-3.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2014c5b3ff15e64feecb6b713af12093f75b7926049e26a580e94dcad3c73d8c", size = 29732 }, + { url = "https://files.pythonhosted.org/packages/0c/67/f75276ca39e2c6604e3bee6c84e9db8a56a4973fde9bf35989787cf6e8aa/xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fab81ef75003eda96239a23eda4e4543cedc22e34c373edcaf744e721a163986", size = 36214 }, + { url = "https://files.pythonhosted.org/packages/0f/f8/f6c61fd794229cc3848d144f73754a0c107854372d7261419dcbbd286299/xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e2febf914ace002132aa09169cc572e0d8959d0f305f93d5828c4836f9bc5a6", size = 32020 }, + { url = "https://files.pythonhosted.org/packages/79/d3/c029c99801526f859e6b38d34ab87c08993bf3dcea34b11275775001638a/xxhash-3.5.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d3a10609c51da2a1c0ea0293fc3968ca0a18bd73838455b5bca3069d7f8e32b", size = 40515 }, + { url = "https://files.pythonhosted.org/packages/62/e3/bef7b82c1997579c94de9ac5ea7626d01ae5858aa22bf4fcb38bf220cb3e/xxhash-3.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5a74f23335b9689b66eb6dbe2a931a88fcd7a4c2cc4b1cb0edba8ce381c7a1da", size = 30064 }, + { url = "https://files.pythonhosted.org/packages/c2/56/30d3df421814947f9d782b20c9b7e5e957f3791cbd89874578011daafcbd/xxhash-3.5.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:531af8845aaadcadf951b7e0c1345c6b9c68a990eeb74ff9acd8501a0ad6a1c9", size = 29734 }, + { url = "https://files.pythonhosted.org/packages/82/dd/3c42a1f022ad0d82c852d3cb65493ebac03dcfa8c994465a5fb052b00e3c/xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ce379bcaa9fcc00f19affa7773084dd09f5b59947b3fb47a1ceb0179f91aaa1", size = 36216 }, + { url = "https://files.pythonhosted.org/packages/b2/40/8f902ab3bebda228a9b4de69eba988280285a7f7f167b942bc20bb562df9/xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd1b2281d01723f076df3c8188f43f2472248a6b63118b036e641243656b1b0f", size = 32042 }, + { url = "https://files.pythonhosted.org/packages/db/87/bd06beb8ccaa0e9e577c9b909a49cfa5c5cd2ca46034342d72dd9ce5bc56/xxhash-3.5.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c770750cc80e8694492244bca7251385188bc5597b6a39d98a9f30e8da984e0", size = 40516 }, + { url = "https://files.pythonhosted.org/packages/bb/f8/505385e2fbd753ddcaafd5550eabe86f6232cbebabad3b2508d411b19153/xxhash-3.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b150b8467852e1bd844387459aa6fbe11d7f38b56e901f9f3b3e6aba0d660240", size = 30108 }, +] From 62c10d0743d7a0c4c7db641ab0ed6d937f07253f Mon Sep 17 00:00:00 2001 From: o-murphy Date: Sun, 27 Jul 2025 21:02:33 +0300 Subject: [PATCH 3/3] aioconsole.ainput/print compatibility patches, pytest CI --- .github/workflows/pytest.yml | 30 +++++ VERSION.txt | 2 +- pyproject.toml | 10 +- src/bitchat_python/_compat/__init__.py | 1 + src/bitchat_python/_compat/ainput.py | 63 ++++++++++ src/bitchat_python/_compat/pythonista.py | 114 ++++++++++++++++++ src/bitchat_python/bitchat.py | 6 +- src/bitchat_python/encryption.py | 3 + tests/__init__.py | 0 tests/conftest.py | 3 + .../test_identity_announcement.py | 105 ++++++++-------- uv.lock | 77 ++++++++++++ 12 files changed, 347 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/pytest.yml create mode 100644 src/bitchat_python/_compat/__init__.py create mode 100644 src/bitchat_python/_compat/ainput.py create mode 100644 src/bitchat_python/_compat/pythonista.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py rename test_identity_announcement.py => tests/test_identity_announcement.py (77%) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..0000045 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,30 @@ +name: Pytest + +on: + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + testing: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install the project + run: | + uv sync --dev + + - name: Run unittests with pytest + run: | + uv run pytest \ No newline at end of file diff --git a/VERSION.txt b/VERSION.txt index afaf360..fdd3c29 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.0.0 \ No newline at end of file +0.1.dev18+g35a4355.d20250727 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 8f120aa..8bc63d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,9 +62,9 @@ version_file = "VERSION.txt" bitchat-python = "bitchat_python.__main__:main" -# # uncomment to configure pytest -#[tool.pytest] -#testpaths = ["tests"] +# uncomment to configure pytest +[tool.pytest] +testpaths = ["tests"] [tool.mypy] packages = ["bitchat_python"] @@ -81,7 +81,7 @@ extend-exclude = ["__init__.py"] [dependency-groups] dev = [ "mypy>=1.15.0", + "pytest>=8.4.1", "ruff>=0.12.1", -# "pytest>=8.4.1", -# "pytest-cov>=6.2.1", + # "pytest-cov>=6.2.1", ] diff --git a/src/bitchat_python/_compat/__init__.py b/src/bitchat_python/_compat/__init__.py new file mode 100644 index 0000000..bb6260c --- /dev/null +++ b/src/bitchat_python/_compat/__init__.py @@ -0,0 +1 @@ +from .ainput import ainput diff --git a/src/bitchat_python/_compat/ainput.py b/src/bitchat_python/_compat/ainput.py new file mode 100644 index 0000000..bdeceec --- /dev/null +++ b/src/bitchat_python/_compat/ainput.py @@ -0,0 +1,63 @@ +"""Compatibility module""" + +import asyncio +import sys + +from bitchat_python._logger import logger + +try: + import aioconsole as _aioconsole + + _ainput = _aioconsole.ainput +except ImportError: + _aioconsole = None + _ainput = None + + +async def _ainput_fallback(prompt): + # ainput() Fallback uses if aioconsole.ainput() raises TypeError, EOF + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, input, prompt) + + +# section to apply I/O patches for Pythonista iOS app +if "Pythonista3.app" in sys.executable: + from bitchat_python._compat.pythonista import ( + pythonista_aioconsole_ainput_patched, + pythonista_get_ansi_print, + ) + + # ainput() patch + if _aioconsole is not None: + _ainput = pythonista_aioconsole_ainput_patched + + # print() patch + try: + import console + import builtins + + builtins.print = pythonista_get_ansi_print(console) + except ImportError: + # fallback to default print + pass + +# ainput() Fallback +if _ainput is None: + _ainput = _ainput_fallback + + +async def ainput(prompt): + global _ainput + try: + return await _ainput(prompt) + except (EOFError, TypeError) as err: + logger.warning(f"ainput error: {err}, fallback to executor") + _ainput = _ainput_fallback + raise + + +if __name__ == "__main__": + import logging + + logging.basicConfig(level=logging.DEBUG) + asyncio.run(ainput("> ")) diff --git a/src/bitchat_python/_compat/pythonista.py b/src/bitchat_python/_compat/pythonista.py new file mode 100644 index 0000000..deb1abf --- /dev/null +++ b/src/bitchat_python/_compat/pythonista.py @@ -0,0 +1,114 @@ +""" +Patches that allow using bitchat_python in Pythonista iOS app +""" + +import re +import sys + +import aioconsole as _aioconsole + +_BASIC_ANSI_RGB = { + 30: (0, 0, 0), + 31: (128, 0, 0), + 32: (0, 128, 0), + 33: (128, 128, 0), + 34: (0, 0, 128), + 35: (128, 0, 128), + 36: (0, 128, 128), + 37: (192, 192, 192), + 90: (128, 128, 128), + 91: (255, 0, 0), + 92: (0, 255, 0), + 93: (255, 255, 0), + 94: (0, 0, 255), + 95: (255, 0, 255), + 96: (0, 255, 255), + 97: (255, 255, 255), +} + +_ANSI_PATTERN = re.compile(r"\x1B\[[0-9;?]*[A-Za-z]") +_ALLOWED_PATTERN = re.compile(r"\x1B\[[0-9;]*m") + + +def ansi_to_rgb(code): + if isinstance(code, str): + m = re.match(r"(?:38|48);2;(\d+);(\d+);(\d+)", code) + if m: + return tuple(map(int, m.groups())) + try: + code = int(code) + except ValueError: + return None + + if code in _BASIC_ANSI_RGB: + return _BASIC_ANSI_RGB[code] + + if 16 <= code <= 231: + c = code - 16 + r = (c // 36) % 6 + g = (c // 6) % 6 + b = c % 6 + return (r * 51, g * 51, b * 51) + + if 232 <= code <= 255: + gray = (code - 232) * 10 + 8 + return (gray, gray, gray) + + return None + + +async def pythonista_aioconsole_ainput_patched( + prompt="", *, streams=None, use_stderr=False, loop=None +): + """ + Asynchronous equivalent to *input*. + Patched for Pythonista iOS app console compatibility + """ + # Get standard streams + if streams is None: + streams = await _aioconsole.get_standard_streams( + use_stderr=use_stderr, loop=loop + ) + reader, writer = streams + # Write prompt + writer.write(prompt.encode()) + await writer.drain() + # Get data + data = await reader.readline() + # Decode data + data = data.decode() + # Return or raise EOF + + # NOTE: pythonista console not handles "\n" on return + # if not data.endswith("\n"): + # raise EOFError + + return data.rstrip("\n") + + +def pythonista_get_ansi_print(console): + def _print(*objects, sep=" ", end="\n", file=None, flush=False): + text = sep.join(str(obj) for obj in objects) + end + + ansi_regex = r"\x1b\[([\d;]+)m" + parts = re.split(ansi_regex, text) + + for i, part in enumerate(parts): + if i % 2 == 1: # ANSI code block + codes = part.split(";") + for c in codes: + if c == "0": + console.set_color() + ... + else: + rgb = ansi_to_rgb(c) + if rgb: + console.set_color(*rgb) + ... + else: + # This is visible text (may contain non-color ANSI → remove them) + visible = _ANSI_PATTERN.sub("", part) + sys.stdout.write(visible) + sys.stdout.flush() + + return _print diff --git a/src/bitchat_python/bitchat.py b/src/bitchat_python/bitchat.py index 7d03c7e..81bc977 100644 --- a/src/bitchat_python/bitchat.py +++ b/src/bitchat_python/bitchat.py @@ -16,13 +16,13 @@ from random import randint from typing import Optional, Dict, List, Tuple, Set -import aioconsole # type: ignore[import-untyped] from bleak import BleakClient, BleakScanner, BleakGATTCharacteristic from bleak.backends.device import BLEDevice from pybloom_live import BloomFilter # type: ignore[import-untyped] from bitchat_python._logger import logger, enable_file_logging from bitchat_python._version import __version__ +from bitchat_python._compat import ainput from bitchat_python.compression import decompress from bitchat_python.encryption import EncryptionService, NoiseError from bitchat_python.persistence import ( @@ -1921,7 +1921,7 @@ async def handle_user_input(self, line: str): if line == "/switch": print(f"\n{self.chat_context.get_conversation_list_with_numbers()}") - switch_input = await aioconsole.ainput("Enter number to switch to: ") + switch_input = await ainput("Enter number to switch to: ") if switch_input.strip().isdigit(): num = int(switch_input.strip()) if self.chat_context.switch_to_number(num): @@ -2936,7 +2936,7 @@ async def input_loop(self): """Handle user input asynchronously""" while self.running: try: - line = await aioconsole.ainput("> ") + line = await ainput("> ") await self.handle_user_input(line) except KeyboardInterrupt: self.running = False diff --git a/src/bitchat_python/encryption.py b/src/bitchat_python/encryption.py index 0f4f9bc..0f6325e 100644 --- a/src/bitchat_python/encryption.py +++ b/src/bitchat_python/encryption.py @@ -18,6 +18,8 @@ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 from cryptography.hazmat.primitives.hmac import HMAC +from bitchat_python._logger import logger + # Noise Protocol Constants NOISE_PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_SHA256" NOISE_DH_LEN = 32 # Curve25519 key size @@ -465,6 +467,7 @@ def decrypt(self, ciphertext: bytes, associated_data: bytes = b"") -> bytes: # print(f"[NOISE] NoiseCipher.decrypt: SUCCESS, plaintext_len={len(plaintext)}") return plaintext except Exception as e: + logger.debug(f"Error decrypting ciphertext: {e}") # print(f"[NOISE] NoiseCipher.decrypt: FAILED with {type(e).__name__}: {e}") # print(f"[NOISE] NoiseCipher.decrypt: key={self.key.hex()[:32]}...") # Increment nonce even on failure to maintain sync (Noise protocol requirement) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..095a045 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import logging +logging.basicConfig(level=logging.DEBUG) + diff --git a/test_identity_announcement.py b/tests/test_identity_announcement.py similarity index 77% rename from test_identity_announcement.py rename to tests/test_identity_announcement.py index 4ce848b..40443e6 100644 --- a/test_identity_announcement.py +++ b/tests/test_identity_announcement.py @@ -4,18 +4,20 @@ Test script for Noise Identity Announcement binary encoding/decoding """ -import time import os +import time + +import pytest def encode_noise_identity_announcement_binary( - peer_id: str, - public_key: bytes, - signing_public_key: bytes, - nickname: str, - timestamp: int, - signature: bytes, - previous_peer_id: str = None, + peer_id: str, + public_key: bytes, + signing_public_key: bytes, + nickname: str, + timestamp: int, + signature: bytes, + previous_peer_id: str = None, ) -> bytes: """Encode noise identity announcement to binary format matching iOS""" data = bytearray() @@ -74,47 +76,47 @@ def parse_noise_identity_announcement_binary(data: bytes) -> dict: # Read peerID (8 bytes) if offset + 8 > len(data): raise ValueError("Insufficient data for peerID") - peer_id_bytes = data[offset : offset + 8] + peer_id_bytes = data[offset: offset + 8] peer_id = peer_id_bytes.hex() offset += 8 # Read publicKey (length-prefixed) if offset + 4 > len(data): raise ValueError("Insufficient data for publicKey length") - public_key_len = int.from_bytes(data[offset : offset + 4], "little") + public_key_len = int.from_bytes(data[offset: offset + 4], "little") offset += 4 if offset + public_key_len > len(data): raise ValueError("Insufficient data for publicKey") - public_key = data[offset : offset + public_key_len] + public_key = data[offset: offset + public_key_len] offset += public_key_len # Read signingPublicKey (length-prefixed) if offset + 4 > len(data): raise ValueError("Insufficient data for signingPublicKey length") - signing_key_len = int.from_bytes(data[offset : offset + 4], "little") + signing_key_len = int.from_bytes(data[offset: offset + 4], "little") offset += 4 if offset + signing_key_len > len(data): raise ValueError("Insufficient data for signingPublicKey") - signing_public_key = data[offset : offset + signing_key_len] + signing_public_key = data[offset: offset + signing_key_len] offset += signing_key_len # Read nickname (length-prefixed string) if offset + 4 > len(data): raise ValueError("Insufficient data for nickname length") - nickname_len = int.from_bytes(data[offset : offset + 4], "little") + nickname_len = int.from_bytes(data[offset: offset + 4], "little") offset += 4 if offset + nickname_len > len(data): raise ValueError("Insufficient data for nickname") - nickname = data[offset : offset + nickname_len].decode("utf-8") + nickname = data[offset: offset + nickname_len].decode("utf-8") offset += nickname_len # Read timestamp (8 bytes) if offset + 8 > len(data): raise ValueError("Insufficient data for timestamp") - timestamp = int.from_bytes(data[offset : offset + 8], "little") + timestamp = int.from_bytes(data[offset: offset + 8], "little") offset += 8 # Read previousPeerID if present @@ -122,7 +124,7 @@ def parse_noise_identity_announcement_binary(data: bytes) -> dict: if has_previous_peer_id: if offset + 8 > len(data): raise ValueError("Insufficient data for previousPeerID") - prev_peer_bytes = data[offset : offset + 8] + prev_peer_bytes = data[offset: offset + 8] previous_peer_id = prev_peer_bytes.hex() offset += 8 @@ -170,8 +172,7 @@ def test_identity_announcement(): print(f"\n✓ Encoding successful: {len(encoded)} bytes") print(f" First 32 bytes: {encoded[:32].hex()}") except Exception as e: - print(f"✗ Encoding failed: {e}") - return False + pytest.fail(f"✗ Encoding failed: {e}") # Test decoding try: @@ -184,46 +185,36 @@ def test_identity_announcement(): print(f" Decoded Signing Key: {decoded['signingPublicKey'][:32]}...") print(f" Decoded Signature: {decoded['signature'][:32]}...") except Exception as e: - print(f"✗ Decoding failed: {e}") - return False + pytest.fail(f"✗ Decoding failed: {e}") # Verify round-trip print(f"\nVerifying round-trip...") success = True if decoded["peerID"] != peer_id: - print(f"✗ Peer ID mismatch: {decoded['peerID']} != {peer_id}") - success = False + pytest.fail(f"✗ Peer ID mismatch: {decoded['peerID']} != {peer_id}") if decoded["publicKey"] != public_key.hex(): - print(f"✗ Public key mismatch") - success = False + pytest.fail(f"✗ Public key mismatch") if decoded["signingPublicKey"] != signing_public_key.hex(): - print(f"✗ Signing public key mismatch") - success = False + pytest.fail(f"✗ Signing public key mismatch") if decoded["nickname"] != nickname: - print(f"✗ Nickname mismatch: {decoded['nickname']} != {nickname}") - success = False + pytest.fail(f"✗ Nickname mismatch: {decoded['nickname']} != {nickname}") if decoded["timestamp"] != timestamp: - print(f"✗ Timestamp mismatch: {decoded['timestamp']} != {timestamp}") - success = False + pytest.fail(f"✗ Timestamp mismatch: {decoded['timestamp']} != {timestamp}") if decoded["signature"] != signature.hex(): - print(f"✗ Signature mismatch") - success = False + pytest.fail(f"✗ Signature mismatch") if decoded["previousPeerID"] is not None: - print(f"✗ Previous peer ID should be None, got: {decoded['previousPeerID']}") - success = False + pytest.fail(f"✗ Previous peer ID should be None, got: {decoded['previousPeerID']}") if success: print("✓ Round-trip verification successful!") - return success - def test_with_previous_peer_id(): """Test with previous peer ID set""" @@ -257,30 +248,28 @@ def test_with_previous_peer_id(): # Verify if decoded["previousPeerID"] != previous_peer_id: - print( + pytest.fail( f"✗ Previous peer ID mismatch: {decoded['previousPeerID']} != {previous_peer_id}" ) - return False print(f"✓ Previous peer ID correctly preserved: {decoded['previousPeerID']}") - return True except Exception as e: - print(f"✗ Test with previous peer ID failed: {e}") - return False - - -if __name__ == "__main__": - print("=" * 60) - print("Noise Identity Announcement Binary Format Test") - print("=" * 60) - - success1 = test_identity_announcement() - success2 = test_with_previous_peer_id() - - print("\n" + "=" * 60) - if success1 and success2: - print("🎉 All tests passed!") - else: - print("❌ Some tests failed!") - print("=" * 60) + pytest.fail(f"✗ Test with previous peer ID failed: {e}") + +# # NOTE: commented because of `pytest` usage +# +# if __name__ == "__main__": +# print("=" * 60) +# print("Noise Identity Announcement Binary Format Test") +# print("=" * 60) +# +# success1 = test_identity_announcement() +# success2 = test_with_previous_peer_id() +# +# print("\n" + "=" * 60) +# if success1 and success2: +# print("🎉 All tests passed!") +# else: +# print("❌ Some tests failed!") +# print("=" * 60) diff --git a/uv.lock b/uv.lock index 03a74da..f18c723 100644 --- a/uv.lock +++ b/uv.lock @@ -128,6 +128,7 @@ full = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pytest" }, { name = "ruff" }, ] @@ -144,6 +145,7 @@ provides-extras = ["full"] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.15.0" }, + { name = "pytest", specifier = ">=8.4.1" }, { name = "ruff", specifier = ">=0.12.1" }, ] @@ -241,6 +243,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + [[package]] name = "cryptography" version = "45.0.5" @@ -333,6 +344,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/77/f1d6afe5a05cbc3fed3d8a96231548664427c341f4bf35f5c7318c0be665/dbus_fast-2.44.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:890f0fc046d5db66524ddedeca8c14b65739fbbf32d6488175c07428362bf250", size = 754540 }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + [[package]] name = "lz4" version = "4.4.4" @@ -435,6 +467,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -444,6 +485,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + [[package]] name = "pybloom-live" version = "4.0.0" @@ -463,6 +513,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + [[package]] name = "pyobjc-core" version = "11.1" @@ -538,6 +597,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/4c/4ef43d2ee85e55a73cfb5090cf29d2f1a5d82e6fe81623b62b7e008afe33/pyobjc_framework_libdispatch-11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cfe515f4c3ea66c13fce4a527230027517b8b779b40bbcb220ff7cdf3ad20bc4", size = 20435 }, ] +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 }, +] + [[package]] name = "ruff" version = "0.12.3"