diff --git a/src/nostr/client.py b/src/nostr/client.py index 7157d98..f4cff20 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -3,14 +3,21 @@ import base64 import hashlib import json import logging -import time -import uuid from pathlib import Path from typing import Callable, List, Optional -from pynostr.websocket_relay_manager import WebSocketRelayManager -from pynostr.event import Event, EventKind -from pynostr.encrypted_dm import EncryptedDirectMessage +from nostr_sdk import nostr_sdk as sdk +from nostr_sdk import uniffi_set_event_loop + +# 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 password_manager.encryption import EncryptionManager @@ -28,7 +35,7 @@ DEFAULT_RELAYS = [ class NostrClient: - """Interact with the Nostr network using pynostr.""" + """Interact with the Nostr network using nostr-sdk.""" def __init__( self, @@ -49,47 +56,58 @@ class NostrClient: self.initialize_client_pool() def initialize_client_pool(self) -> None: - """Create the relay manager and connect to configured relays.""" - self.client_pool = WebSocketRelayManager() - for relay in self.relays: - self.client_pool.add_relay(relay) + """Create the client and connect to configured relays.""" - async def publish_event_async(self, event: Event) -> None: - logger.debug("Publishing event %s", event.id) - self.client_pool.publish_event(event) + 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() - def publish_event(self, event: Event) -> None: - self.client_pool.publish_event(event) + 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[[WebSocketRelayManager, str, Event], None], + handler: Callable[[object, str, object], None], timeout: float = 2.0, ) -> None: - sub_id = str(uuid.uuid4()) - from pynostr.filters import FiltersList + 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"]) - filter_list = FiltersList.from_json_array(filters) - self.client_pool.add_subscription_on_all_relays(sub_id, filter_list) - self.subscriptions.add(sub_id) - - end = asyncio.get_event_loop().time() + timeout - try: - while asyncio.get_event_loop().time() < end: - while self.client_pool.message_pool.has_events(): - msg = self.client_pool.message_pool.get_event() - if msg.subscription_id == sub_id: - handler(self.client_pool, sub_id, msg.event) - await asyncio.sleep(0.1) - finally: - self.client_pool.close_subscription_on_all_relays(sub_id) - self.subscriptions.discard(sub_id) + events = await self.client_pool.fetch_events(flt, Duration(seconds=timeout)) + for evt in events: + handler(self.client_pool, "0", evt) def subscribe( self, filters: List[dict], - handler: Callable[[WebSocketRelayManager, str, Event], None], + handler: Callable[[object, str, object], None], timeout: float = 2.0, ) -> None: asyncio.run(self.subscribe_async(filters, handler, timeout)) @@ -98,13 +116,13 @@ class NostrClient: filters = [ { "authors": [self.key_manager.keys.public_key_hex()], - "kinds": [EventKind.TEXT_NOTE, EventKind.ENCRYPTED_DIRECT_MESSAGE], + "kinds": [1, 4], "limit": 1, } ] - events: list[Event] = [] + events: list = [] - async def handler(_client, _sid, evt: Event): + async def handler(_client, _sid, evt): events.append(evt) await self.subscribe_async(filters, handler) @@ -113,32 +131,26 @@ class NostrClient: return None event = events[0] - content_base64 = event.content - if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE: - dm = EncryptedDirectMessage.from_event(event) - dm.decrypt( - self.key_manager.keys.private_key_hex(), public_key_hex=dm.pubkey - ) - content_base64 = dm.cleartext_content + 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: - event = Event(kind=EventKind.TEXT_NOTE, content=text) - event.pubkey = self.key_manager.keys.public_key_hex() - event.created_at = int(time.time()) - event.sign(self.key_manager.keys.private_key_hex()) + 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[[WebSocketRelayManager, str, Event], None] + self, handler: Callable[[object, str, object], None] ) -> None: filters = [ { "authors": [self.key_manager.keys.public_key_hex()], - "kinds": [EventKind.TEXT_NOTE, EventKind.ENCRYPTED_DIRECT_MESSAGE], + "kinds": [1, 4], "limit": 100, } ] @@ -190,19 +202,12 @@ class NostrClient: ) -> bool: try: content = base64.b64encode(encrypted_json).decode("utf-8") - if to_pubkey: - dm = EncryptedDirectMessage() - dm.encrypt( - private_key_hex=self.key_manager.keys.private_key_hex(), - cleartext_content=content, - recipient_pubkey=to_pubkey, - ) - event = dm.to_event() - else: - event = Event(kind=EventKind.TEXT_NOTE, content=content) - event.pubkey = self.key_manager.keys.public_key_hex() - event.created_at = int(time.time()) - event.sign(self.key_manager.keys.private_key_hex()) + keys = Keys.parse(self.key_manager.keys.private_key_hex()) + event = ( + EventBuilder.text_note(content) + .build(keys.public_key()) + .sign_with_keys(keys) + ) self.publish_event(event) return True except Exception as e: # pragma: no cover - defensive @@ -219,13 +224,14 @@ class NostrClient: self.decrypt_and_save_index_from_nostr(encrypted_data) async def close_client_pool_async(self) -> None: - self.client_pool.close_all_relay_connections() + uniffi_set_event_loop(asyncio.get_running_loop()) + await self.client_pool.disconnect() def close_client_pool(self) -> None: - self.client_pool.close_all_relay_connections() + asyncio.run(self.close_client_pool_async()) async def safe_close_connection(self, client): # pragma: no cover - compatibility try: - await client.close_connection() + await client.disconnect() except Exception: pass diff --git a/src/requirements.txt b/src/requirements.txt index 28a26a8..e49a4e8 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -11,7 +11,7 @@ bip85 pytest>=7.0 pytest-cov portalocker>=2.8 -pynostr>=0.6.2 +nostr-sdk>=0.42.1 websocket-client==1.7.0 websockets>=15.0.0 diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 5e43353..7daac71 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -16,11 +16,11 @@ def test_nostr_client_uses_custom_relays(): enc_mgr = EncryptionManager(key, Path(tmpdir)) custom_relays = ["wss://relay1", "wss://relay2"] - with patch("nostr.client.WebSocketRelayManager") as MockPool, patch( + with patch("nostr.client.ClientBuilder") as MockBuilder, patch( "nostr.client.KeyManager" - ): + ), patch.object(NostrClient, "initialize_client_pool"): + mock_builder = MockBuilder.return_value with patch.object(enc_mgr, "decrypt_parent_seed", return_value="seed"): client = NostrClient(enc_mgr, "fp", relays=custom_relays) - MockPool.assert_called_with() assert client.relays == custom_relays diff --git a/src/tests/test_pynostr_workflow.py b/src/tests/test_nostr_sdk_workflow.py similarity index 82% rename from src/tests/test_pynostr_workflow.py rename to src/tests/test_nostr_sdk_workflow.py index 0045975..e601a14 100644 --- a/src/tests/test_pynostr_workflow.py +++ b/src/tests/test_nostr_sdk_workflow.py @@ -4,9 +4,10 @@ import threading import time from websocket import create_connection +import asyncio import websockets from nostr.key_manager import KeyManager -from pynostr.event import Event, EventKind +from nostr_sdk import nostr_sdk as sdk class FakeRelay: @@ -35,7 +36,7 @@ def run_relay(relay, host="localhost", port=8765): asyncio.run(main()) -def test_pynostr_send_receive(tmp_path): +def test_nostr_sdk_send_receive(tmp_path): relay = FakeRelay() thread = threading.Thread(target=run_relay, args=(relay,), daemon=True) thread.start() @@ -48,12 +49,13 @@ def test_pynostr_send_receive(tmp_path): ws = create_connection("ws://localhost:8765") - event = Event(kind=EventKind.TEXT_NOTE, content="hello") - event.pubkey = km.get_public_key_hex() - event.created_at = int(time.time()) - event.sign(km.get_private_key_hex()) - - ws.send(event.to_message()) + keys = sdk.Keys.parse(km.get_private_key_hex()) + event = ( + sdk.EventBuilder.text_note("hello") + .build(keys.public_key()) + .sign_with_keys(keys) + ) + ws.send(json.dumps(["EVENT", json.loads(event.as_json())])) sub_id = "1" ws.send(json.dumps(["REQ", sub_id, {}])) diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index af3e713..f3e55c9 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -14,31 +14,40 @@ def setup_client(tmp_path): key = Fernet.generate_key() enc_mgr = EncryptionManager(key, tmp_path) - with patch("nostr.client.WebSocketRelayManager"), patch( + with patch("nostr.client.ClientBuilder"), patch( "nostr.client.KeyManager" - ), patch.object(NostrClient, "initialize_client_pool"), patch.object( + ) as MockKM, patch.object(NostrClient, "initialize_client_pool"), patch.object( enc_mgr, "decrypt_parent_seed", return_value="seed" ): + km_inst = MockKM.return_value + km_inst.keys.private_key_hex.return_value = "1" * 64 + km_inst.keys.public_key_hex.return_value = "2" * 64 client = NostrClient(enc_mgr, "fp") return client class FakeEvent: - KIND_TEXT_NOTE = 1 - KIND_ENCRYPT = 2 + def __init__(self): + self._id = "id" - def __init__(self, kind, content, pub_key=None): - self.kind = kind - self.content = content - self.pubkey = pub_key - self.id = "id" + def id(self): + return self._id - def sign(self, _): - pass + +class FakeUnsignedEvent: + def sign_with_keys(self, _): + return FakeEvent() + + +class FakeBuilder: + def build(self, _): + return FakeUnsignedEvent() def test_publish_json_success(): - with TemporaryDirectory() as tmpdir, patch("nostr.client.Event", FakeEvent): + with TemporaryDirectory() as tmpdir, patch( + "nostr.client.EventBuilder.text_note", return_value=FakeBuilder() + ): client = setup_client(Path(tmpdir)) with patch.object(client, "publish_event") as mock_pub: assert client.publish_json_to_nostr(b"data") is True @@ -46,7 +55,9 @@ def test_publish_json_success(): def test_publish_json_failure(): - with TemporaryDirectory() as tmpdir, patch("nostr.client.Event", FakeEvent): + with TemporaryDirectory() as tmpdir, patch( + "nostr.client.EventBuilder.text_note", return_value=FakeBuilder() + ): client = setup_client(Path(tmpdir)) with patch.object(client, "publish_event", side_effect=Exception("boom")): assert client.publish_json_to_nostr(b"data") is False diff --git a/tests/test_nostr_backup.py b/tests/test_nostr_backup.py index cdfbcc8..81a2b9d 100644 --- a/tests/test_nostr_backup.py +++ b/tests/test_nostr_backup.py @@ -27,7 +27,7 @@ def test_backup_and_publish_to_nostr(): with patch( "nostr.client.NostrClient.publish_json_to_nostr", return_value=True - ) as mock_publish, patch("nostr.client.WebSocketRelayManager"), patch( + ) as mock_publish, patch("nostr.client.ClientBuilder"), patch( "nostr.client.KeyManager" ), patch.object( NostrClient, "initialize_client_pool"