mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Merge pull request #70 from PR0M3TH3AN/codex/switch-to-nostr-sdk-and-update-tests
Switch to nostr-sdk
This commit is contained in:
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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, {}]))
|
||||
|
@@ -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
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user