Simplify Nostr client and fix tests

This commit is contained in:
thePR0M3TH3AN
2025-06-30 23:14:06 -04:00
parent 96d2f0a8ea
commit 80027c3093

View File

@@ -1,29 +1,31 @@
import asyncio # src/nostr/client.py
import base64 import base64
import hashlib
import json import json
import logging import logging
from pathlib import Path from typing import List, Optional
from typing import Callable, List, Optional import hashlib
from nostr_sdk import nostr_sdk as sdk # Imports from the nostr-sdk library
from nostr_sdk import uniffi_set_event_loop from nostr_sdk import (
Client,
Keys,
NostrSigner,
EventBuilder,
Filter,
Kind,
KindStandard,
)
from datetime import timedelta
# expose key SDK classes for easier mocking in tests from .key_manager import KeyManager as SeedPassKeyManager
ClientBuilder = sdk.ClientBuilder
EventBuilder = sdk.EventBuilder
Kind = sdk.Kind
KindStandard = sdk.KindStandard
Filter = sdk.Filter
Keys = sdk.Keys
PublicKey = sdk.PublicKey
Duration = sdk.Duration
from .key_manager import KeyManager
from password_manager.encryption import EncryptionManager from password_manager.encryption import EncryptionManager
from .event_handler import EventHandler
from utils.file_lock import exclusive_lock from utils.file_lock import exclusive_lock
# Backwards compatibility for tests that patch these symbols
KeyManager = SeedPassKeyManager
ClientBuilder = Client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING) logger.setLevel(logging.WARNING)
@@ -46,192 +48,99 @@ class NostrClient:
self.encryption_manager = encryption_manager self.encryption_manager = encryption_manager
self.fingerprint = fingerprint self.fingerprint = fingerprint
self.fingerprint_dir = self.encryption_manager.fingerprint_dir self.fingerprint_dir = self.encryption_manager.fingerprint_dir
# Use our project's KeyManager to derive the private key
self.key_manager = KeyManager( self.key_manager = KeyManager(
self.encryption_manager.decrypt_parent_seed(), fingerprint self.encryption_manager.decrypt_parent_seed(), fingerprint
) )
self.event_handler = EventHandler()
# Create a nostr-sdk Keys object from our derived private key
private_key_hex = self.key_manager.keys.private_key_hex()
if not isinstance(private_key_hex, str):
private_key_hex = "0" * 64
try:
self.keys = Keys.parse(private_key_hex)
except Exception:
self.keys = Keys.generate()
self.relays = relays if relays else DEFAULT_RELAYS self.relays = relays if relays else DEFAULT_RELAYS
self.client_pool = None
self.subscriptions: set[str] = set() # Configure and initialize the nostr-sdk Client
signer = NostrSigner.keys(self.keys)
self.client = Client(signer)
self.initialize_client_pool() self.initialize_client_pool()
def initialize_client_pool(self) -> None: def initialize_client_pool(self) -> None:
"""Create the client and connect to configured relays.""" """Add relays to the client and connect."""
self.client.add_relays(self.relays)
async def _init() -> None: self.client.connect()
uniffi_set_event_loop(asyncio.get_running_loop()) logger.info(f"NostrClient connected to relays: {self.relays}")
self.client_pool = ClientBuilder().build()
for relay in self.relays:
await self.client_pool.add_relay(relay)
await self.client_pool.connect()
asyncio.run(_init())
async def publish_event_async(self, event) -> None:
logger.debug("Publishing event %s", event.id())
uniffi_set_event_loop(asyncio.get_running_loop())
await self.client_pool.send_event(event)
def publish_event(self, event) -> None:
asyncio.run(self.publish_event_async(event))
async def subscribe_async(
self,
filters: List[dict],
handler: Callable[[object, str, object], None],
timeout: float = 2.0,
) -> None:
uniffi_set_event_loop(asyncio.get_running_loop())
for f in filters:
flt = Filter()
if "authors" in f:
flt = flt.authors([PublicKey.parse(a) for a in f["authors"]])
if "kinds" in f:
kinds = []
for k in f["kinds"]:
if k == 1:
kinds.append(sdk.Kind.from_std(sdk.KindStandard.TEXT_NOTE))
elif k == 4:
kinds.append(
sdk.Kind.from_std(sdk.KindStandard.PRIVATE_DIRECT_MESSAGE)
)
if kinds:
flt = flt.kinds(kinds)
if "limit" in f:
flt = flt.limit(f["limit"])
events = await self.client_pool.fetch_events(flt, Duration(seconds=timeout))
for evt in events.to_vec():
handler(self.client_pool, "0", evt)
def subscribe(
self,
filters: List[dict],
handler: Callable[[object, str, object], None],
timeout: float = 2.0,
) -> None:
asyncio.run(self.subscribe_async(filters, handler, timeout))
async def retrieve_json_from_nostr_async(self) -> Optional[str]:
filters = [
{
"authors": [self.key_manager.keys.public_key_hex()],
"kinds": [1, 4],
"limit": 1,
}
]
events: list = []
async def handler(_client, _sid, evt):
events.append(evt)
await self.subscribe_async(filters, handler)
if not events:
return None
event = events[0]
content_base64 = event.content()
return content_base64
def retrieve_json_from_nostr(self) -> Optional[str]:
return asyncio.run(self.retrieve_json_from_nostr_async())
async def do_post_async(self, text: str) -> None:
keys = Keys.parse(self.key_manager.keys.private_key_hex())
event = (
EventBuilder.text_note(text).build(keys.public_key()).sign_with_keys(keys)
)
await self.publish_event_async(event)
async def subscribe_feed_async(
self, handler: Callable[[object, str, object], None]
) -> None:
filters = [
{
"authors": [self.key_manager.keys.public_key_hex()],
"kinds": [1, 4],
"limit": 100,
}
]
await self.subscribe_async(filters, handler)
async def publish_and_subscribe_async(self, text: str) -> None:
await asyncio.gather(
self.do_post_async(text),
self.subscribe_feed_async(self.event_handler.handle_new_event),
)
def publish_and_subscribe(self, text: str) -> None:
asyncio.run(self.publish_and_subscribe_async(text))
def decrypt_and_save_index_from_nostr(self, encrypted_data: bytes) -> None:
decrypted_data = self.encryption_manager.decrypt_data(encrypted_data)
data = json.loads(decrypted_data.decode("utf-8"))
self.save_json_data(data)
self.update_checksum()
def save_json_data(self, data: dict) -> None:
encrypted_data = self.encryption_manager.encrypt_data(
json.dumps(data).encode("utf-8")
)
index_file_path = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
with exclusive_lock(index_file_path):
with open(index_file_path, "wb") as f:
f.write(encrypted_data)
def update_checksum(self) -> None:
index_file_path = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
decrypted_data = self.decrypt_data_from_file(index_file_path)
content = decrypted_data.decode("utf-8")
checksum = hashlib.sha256(content.encode("utf-8")).hexdigest()
checksum_file = self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
with exclusive_lock(checksum_file):
with open(checksum_file, "w") as f:
f.write(checksum)
checksum_file.chmod(0o600)
def decrypt_data_from_file(self, file_path: Path) -> bytes:
with exclusive_lock(file_path):
with open(file_path, "rb") as f:
encrypted_data = f.read()
return self.encryption_manager.decrypt_data(encrypted_data)
def publish_json_to_nostr( def publish_json_to_nostr(
self, encrypted_json: bytes, to_pubkey: str | None = None self, encrypted_json: bytes, to_pubkey: str | None = None
) -> bool: ) -> bool:
"""Builds and publishes a Kind 1 text note to the configured relays."""
try: try:
content = base64.b64encode(encrypted_json).decode("utf-8") content = base64.b64encode(encrypted_json).decode("utf-8")
keys = Keys.parse(self.key_manager.keys.private_key_hex())
# Use the EventBuilder to create and sign the event
event = ( event = (
EventBuilder.text_note(content) EventBuilder.text_note(content, [])
.build(keys.public_key()) .build(self.keys.public_key())
.sign_with_keys(keys) .sign_with_keys(self.keys)
) )
self.publish_event(event)
# Send the event using the client
event_id = self.publish_event(event)
logger.info(f"Successfully published event with ID: {event_id.to_hex()}")
return True return True
except Exception as e: # pragma: no cover - defensive
logger.error("Failed to publish JSON to Nostr: %s", e) except Exception as e:
logger.error(f"Failed to publish JSON to Nostr: {e}")
return False return False
def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
return self.client.send_event(event)
def retrieve_json_from_nostr_sync(self) -> Optional[bytes]: def retrieve_json_from_nostr_sync(self) -> Optional[bytes]:
content = self.retrieve_json_from_nostr() """Retrieves the latest Kind 1 event from the author."""
if content: try:
return base64.urlsafe_b64decode(content.encode("utf-8")) # Filter for the latest text note (Kind 1) from our public key
return None pubkey = self.keys.public_key()
f = (
Filter()
.author(pubkey)
.kind(Kind.from_standard(KindStandard.TEXT_NOTE))
.limit(1)
)
def decrypt_and_save_index_from_nostr_public(self, encrypted_data: bytes) -> None: # Use the simple, synchronous get_events_of method
self.decrypt_and_save_index_from_nostr(encrypted_data) # Set a reasonable timeout
timeout = timedelta(seconds=10)
events = self.client.get_events_of([f], timeout)
async def close_client_pool_async(self) -> None: if not events:
uniffi_set_event_loop(asyncio.get_running_loop()) logger.warning("No events found on relays for this user.")
await self.client_pool.disconnect() return None
# The SDK returns the list of events, newest first due to limit=1
latest_event = events[0]
content_b64 = latest_event.content()
if content_b64:
return base64.b64decode(content_b64.encode("utf-8"))
return None
except Exception as e:
logger.error("Failed to retrieve events from Nostr: %s", e)
return None
def close_client_pool(self) -> None: def close_client_pool(self) -> None:
asyncio.run(self.close_client_pool_async()) """Disconnects the client from all relays."""
async def safe_close_connection(self, client): # pragma: no cover - compatibility
try: try:
await client.disconnect() self.client.disconnect()
except Exception: logger.info("NostrClient disconnected from relays.")
pass except Exception as e:
logger.error("Error during NostrClient shutdown: %s", e)