mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 15:28:44 +00:00
Simplify Nostr client and fix tests
This commit is contained in:
@@ -1,29 +1,31 @@
|
||||
import asyncio
|
||||
# src/nostr/client.py
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
from typing import List, Optional
|
||||
import hashlib
|
||||
|
||||
from nostr_sdk import nostr_sdk as sdk
|
||||
from nostr_sdk import uniffi_set_event_loop
|
||||
# Imports from the nostr-sdk library
|
||||
from nostr_sdk import (
|
||||
Client,
|
||||
Keys,
|
||||
NostrSigner,
|
||||
EventBuilder,
|
||||
Filter,
|
||||
Kind,
|
||||
KindStandard,
|
||||
)
|
||||
from datetime import timedelta
|
||||
|
||||
# expose key SDK classes for easier mocking in tests
|
||||
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 .key_manager import KeyManager as SeedPassKeyManager
|
||||
from password_manager.encryption import EncryptionManager
|
||||
from .event_handler import EventHandler
|
||||
from utils.file_lock import exclusive_lock
|
||||
|
||||
# Backwards compatibility for tests that patch these symbols
|
||||
KeyManager = SeedPassKeyManager
|
||||
ClientBuilder = Client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.WARNING)
|
||||
|
||||
@@ -46,192 +48,99 @@ class NostrClient:
|
||||
self.encryption_manager = encryption_manager
|
||||
self.fingerprint = fingerprint
|
||||
self.fingerprint_dir = self.encryption_manager.fingerprint_dir
|
||||
|
||||
# Use our project's KeyManager to derive the private key
|
||||
self.key_manager = KeyManager(
|
||||
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.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()
|
||||
|
||||
def initialize_client_pool(self) -> None:
|
||||
"""Create the client and connect to configured relays."""
|
||||
|
||||
async def _init() -> None:
|
||||
uniffi_set_event_loop(asyncio.get_running_loop())
|
||||
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)
|
||||
"""Add relays to the client and connect."""
|
||||
self.client.add_relays(self.relays)
|
||||
self.client.connect()
|
||||
logger.info(f"NostrClient connected to relays: {self.relays}")
|
||||
|
||||
def publish_json_to_nostr(
|
||||
self, encrypted_json: bytes, to_pubkey: str | None = None
|
||||
) -> bool:
|
||||
"""Builds and publishes a Kind 1 text note to the configured relays."""
|
||||
try:
|
||||
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 = (
|
||||
EventBuilder.text_note(content)
|
||||
.build(keys.public_key())
|
||||
.sign_with_keys(keys)
|
||||
EventBuilder.text_note(content, [])
|
||||
.build(self.keys.public_key())
|
||||
.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
|
||||
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
|
||||
|
||||
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]:
|
||||
content = self.retrieve_json_from_nostr()
|
||||
if content:
|
||||
return base64.urlsafe_b64decode(content.encode("utf-8"))
|
||||
return None
|
||||
"""Retrieves the latest Kind 1 event from the author."""
|
||||
try:
|
||||
# Filter for the latest text note (Kind 1) from our public key
|
||||
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:
|
||||
self.decrypt_and_save_index_from_nostr(encrypted_data)
|
||||
# Use the simple, synchronous get_events_of method
|
||||
# Set a reasonable timeout
|
||||
timeout = timedelta(seconds=10)
|
||||
events = self.client.get_events_of([f], timeout)
|
||||
|
||||
async def close_client_pool_async(self) -> None:
|
||||
uniffi_set_event_loop(asyncio.get_running_loop())
|
||||
await self.client_pool.disconnect()
|
||||
if not events:
|
||||
logger.warning("No events found on relays for this user.")
|
||||
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:
|
||||
asyncio.run(self.close_client_pool_async())
|
||||
|
||||
async def safe_close_connection(self, client): # pragma: no cover - compatibility
|
||||
"""Disconnects the client from all relays."""
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self.client.disconnect()
|
||||
logger.info("NostrClient disconnected from relays.")
|
||||
except Exception as e:
|
||||
logger.error("Error during NostrClient shutdown: %s", e)
|
||||
|
Reference in New Issue
Block a user