From d619b965e71e59143331b523482add58deda684f Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 26 Jun 2023 12:59:58 +0300 Subject: [PATCH 01/34] fix: UI for connected --- templates/nostrclient/index.html | 36 ++++++++++++++++---------------- views_api.py | 4 ---- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/templates/nostrclient/index.html b/templates/nostrclient/index.html index 8018a92..57c3b73 100644 --- a/templates/nostrclient/index.html +++ b/templates/nostrclient/index.html @@ -50,30 +50,30 @@ @@ -252,10 +252,10 @@ relayTable: { columns: [ { - name: 'connected_string', + name: 'connected', align: 'left', label: '', - field: 'connected_string' + field: 'connected' }, { name: 'relay', diff --git a/views_api.py b/views_api.py index f918e3c..b681f50 100644 --- a/views_api.py +++ b/views_api.py @@ -24,10 +24,6 @@ all_routers: list[NostrRouter] = [] async def api_get_relays() -> RelayList: relays = RelayList(__root__=[]) for url, r in nostr.client.relay_manager.relays.items(): - # status_text = ( - # f"⬆️ {r.num_sent_events} ⬇️ {r.num_received_events} ⚠️ {r.error_counter}" - # ) - # connected_text = "🟢" if r.connected else "🔴" relay_id = urlsafe_short_hash() relays.__root__.append( Relay( From ada5b2a51d4c805854adab568fd939edb0e57572 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 29 Jun 2023 15:48:28 +0300 Subject: [PATCH 02/34] fix: unsubscribe --- router.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/router.py b/router.py index 3ee9d0a..264d4a4 100644 --- a/router.py +++ b/router.py @@ -39,7 +39,11 @@ class NostrRouter: self.connected = False break - await self._handle_client_to_nostr(json_str) + try: + await self._handle_client_to_nostr(json_str) + except Exception as e: + logger.debug(f"Failed to handle client message: '{str(e)}'.") + async def nostr_to_client(self): """Sends responses from relays back to the client. Polls the subscriptions of this client @@ -49,10 +53,13 @@ class NostrRouter: that we had previously rewritten in order to avoid collisions when multiple clients use the same id. """ while True and self.connected: - await self._handle_subscriptions() - self._handle_notices() - - await asyncio.sleep(0.1) + try: + await self._handle_subscriptions() + self._handle_notices() + await asyncio.sleep(0.1) + except Exception as e: + logger.debug(f"Failed to handle response for client: '{str(e)}'.") + async def start(self): @@ -112,9 +119,8 @@ class NostrRouter: def _handle_notices(self): while len(NostrRouter.received_subscription_notices): my_event = NostrRouter.received_subscription_notices.pop(0) - event_to_forward = ["NOTICE", my_event.content] # note: we don't send it to the user because we don't know who should receive it - logger.debug("Nostrclient: Received notice: ", event_to_forward[1]) + logger.info(f"Relay ('{my_event.url}') notice: '{my_event.content}']") @@ -143,9 +149,11 @@ class NostrRouter: receive the callbacks on it later. Will rewrite the subscription id since we expect multiple clients to use the router and want to avoid subscription id collisions """ + json_data = json.loads(json_str) assert len(json_data) + if json_data[0] == "REQ": self._handle_client_req(json_data) return @@ -176,4 +184,7 @@ class NostrRouter: def _handle_client_close(self, subscription_id): subscription_id_rewritten = next((k for k, v in self.original_subscription_ids.items() if v == subscription_id), None) if subscription_id_rewritten: + self.original_subscription_ids.pop(subscription_id_rewritten) nostr.client.relay_manager.close_subscription(subscription_id_rewritten) + else: + logger.debug(f"Failed to unsubscribe from '{subscription_id}.'") From 39c2f881c85c1337f5634106e82db72f73f65bf9 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 30 Jun 2023 10:52:16 +0300 Subject: [PATCH 03/34] feat: revive relay after 24 hours from the last error --- nostr/relay.py | 7 +++++-- nostr/relay_manager.py | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/nostr/relay.py b/nostr/relay.py index 4c989c2..da3496d 100644 --- a/nostr/relay.py +++ b/nostr/relay.py @@ -41,6 +41,8 @@ class Relay: self.error_counter: int = 0 self.error_threshold: int = 100 self.error_list: List[str] = [] + self.notice_list: List[str] = [] + self.last_error_date: int = 0 self.num_received_events: int = 0 self.num_sent_events: int = 0 self.num_subscriptions: int = 0 @@ -77,7 +79,7 @@ class Relay: @property def error_threshold_reached(self): - return self.error_threshold and self.error_counter > self.error_threshold + return self.error_threshold and self.error_counter >= self.error_threshold @property def ping(self): @@ -212,4 +214,5 @@ class Relay: return True def _append_error_message(self, message): - self.error_list = ([message] + self.error_list)[:20] \ No newline at end of file + self.error_list = ([message] + self.error_list)[:20] + self.last_error_date = int(time.time()) \ No newline at end of file diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py index 5838308..f8f852c 100644 --- a/nostr/relay_manager.py +++ b/nostr/relay_manager.py @@ -1,6 +1,7 @@ import ssl import threading +import time from loguru import logger @@ -97,7 +98,12 @@ class RelayManager: def _restart_relay(self, relay: Relay): if relay.error_threshold_reached: - return + time_since_last_error = time.time() - relay.last_error_date + if time_since_last_error < 60 * 60 * 24: # last day + return + relay.error_counter = 0 + relay.error_list = [] + logger.info(f"Restarting connection to relay '{relay.url}'") self.remove_relay(relay.url) From f244f60c562f8747e8b9dba91440e177a962271d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 30 Jun 2023 10:53:39 +0300 Subject: [PATCH 04/34] fix: make it 2 hours --- nostr/relay_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py index f8f852c..da650d6 100644 --- a/nostr/relay_manager.py +++ b/nostr/relay_manager.py @@ -99,7 +99,7 @@ class RelayManager: def _restart_relay(self, relay: Relay): if relay.error_threshold_reached: time_since_last_error = time.time() - relay.last_error_date - if time_since_last_error < 60 * 60 * 24: # last day + if time_since_last_error < 60 * 60 * 2: # last day return relay.error_counter = 0 relay.error_list = [] From 1601f71b03ffc2b53e6630f00495d83d50d41583 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 30 Jun 2023 11:46:47 +0300 Subject: [PATCH 05/34] feat: show relay NOTICE --- models.py | 1 + nostr/relay.py | 4 +++- nostr/relay_manager.py | 7 +++++- router.py | 1 + templates/nostrclient/index.html | 38 ++++++++++++++++++++++++++------ views_api.py | 3 ++- 6 files changed, 44 insertions(+), 10 deletions(-) diff --git a/models.py b/models.py index 1006605..88651fc 100644 --- a/models.py +++ b/models.py @@ -13,6 +13,7 @@ class RelayStatus(BaseModel): num_received_events: Optional[int] = 0 error_counter: Optional[int] = 0 error_list: Optional[List] = [] + notice_list: Optional[List] = [] class Relay(BaseModel): id: Optional[str] = None diff --git a/nostr/relay.py b/nostr/relay.py index da3496d..8b081a3 100644 --- a/nostr/relay.py +++ b/nostr/relay.py @@ -128,11 +128,13 @@ class Relay: ], } + def add_notice(self, notice: str): + self.notice_list = ([notice] + self.notice_list)[:20] + def _on_open(self, _): logger.info(f"Connected to relay: '{self.url}'.") self.connected = True - def _on_close(self, _, status_code, message): logger.warning(f"Connection to relay {self.url} closed. Status: '{status_code}'. Message: '{message}'.") self.close() diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py index da650d6..b2df735 100644 --- a/nostr/relay_manager.py +++ b/nostr/relay_manager.py @@ -6,7 +6,7 @@ import time from loguru import logger from .filter import Filters -from .message_pool import MessagePool +from .message_pool import MessagePool, NoticeMessage from .relay import Relay, RelayPolicy from .subscription import Subscription @@ -80,6 +80,11 @@ class RelayManager: if relay.policy.should_write: relay.publish(message) + def handle_notice(self, notice: NoticeMessage): + relay = next((r for r in self.relays.values() if r.url == notice.url)) + if relay: + relay.add_notice(notice.content) + def _open_connection(self, relay: Relay, ssl_options: dict = None, proxy: dict = None): self.threads[relay.url] = threading.Thread( target=relay.connect, diff --git a/router.py b/router.py index 264d4a4..0982f76 100644 --- a/router.py +++ b/router.py @@ -121,6 +121,7 @@ class NostrRouter: my_event = NostrRouter.received_subscription_notices.pop(0) # note: we don't send it to the user because we don't know who should receive it logger.info(f"Relay ('{my_event.url}') notice: '{my_event.content}']") + nostr.client.relay_manager.handle_notice(my_event) diff --git a/templates/nostrclient/index.html b/templates/nostrclient/index.html index 57c3b73..82b149e 100644 --- a/templates/nostrclient/index.html +++ b/templates/nostrclient/index.html @@ -58,12 +58,15 @@
⬆️ ⬇️ - ⚠️ - + + ⚠️ + - -
-
+ + ⓘ + +
+
@@ -198,6 +201,17 @@
+ + + + + +
+ Close +
+
+
+ {% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }} @@ -212,8 +226,8 @@ sentEvents: obj.status.num_sent_events, receveidEvents: obj.status.num_received_events, errorCount: obj.status.error_counter, - errorList: obj.status.error_list - + errorList: obj.status.error_list, + noticeList: obj.status.notice_list } obj.ping = obj.ping + ' ms' @@ -226,6 +240,7 @@ 'HH:mm:ss' ) } + console.log('### obj', obj) return obj } @@ -239,6 +254,10 @@ relayToAdd: '', nostrrelayLinks: [], filter: '', + logData: { + show: false, + data: null + }, testData: { show: false, wsConnection: null, @@ -492,6 +511,11 @@ console.warn(error) } }, + showLogDataDialog: function (data = []) { + console.log('### showLogDataDialog', data) + this.logData.data = data.join('\n') + this.logData.show = true + }, exportlnurldeviceCSV: function () { var self = this LNbits.utils.exportCSV(self.relayTable.columns, this.nostrLinks) diff --git a/views_api.py b/views_api.py index b681f50..b6b4527 100644 --- a/views_api.py +++ b/views_api.py @@ -34,7 +34,8 @@ async def api_get_relays() -> RelayList: "num_sent_events": r.num_sent_events, "num_received_events": r.num_received_events, "error_counter": r.error_counter, - "error_list": r.error_list + "error_list": r.error_list, + "notice_list": r.notice_list, }, ping=r.ping, active=True, From 80b86bf00c751cbcca11701bd9c3ab4ac9f2bd94 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 30 Jun 2023 12:05:28 +0300 Subject: [PATCH 06/34] fix: await even if error --- router.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/router.py b/router.py index 0982f76..e85653c 100644 --- a/router.py +++ b/router.py @@ -56,10 +56,9 @@ class NostrRouter: try: await self._handle_subscriptions() self._handle_notices() - await asyncio.sleep(0.1) except Exception as e: logger.debug(f"Failed to handle response for client: '{str(e)}'.") - + await asyncio.sleep(0.1) async def start(self): From 147af04c20505aa42b21acb9903bf4193a7b504c Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 4 Jul 2023 10:02:47 +0300 Subject: [PATCH 07/34] fix: remove unnecessary `async` --- nostr/client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nostr/client/client.py b/nostr/client/client.py index e033262..66d722c 100644 --- a/nostr/client/client.py +++ b/nostr/client/client.py @@ -14,7 +14,7 @@ class NostrClient: if connect: self.connect() - async def connect(self): + def connect(self): for relay in self.relays: self.relay_manager.add_relay(relay) From 3b08714a8472e4113110efb4a68095dd2b78cba6 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 4 Jul 2023 11:48:50 +0300 Subject: [PATCH 08/34] fix: async aaaa --- nostr/client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nostr/client/client.py b/nostr/client/client.py index 66d722c..e033262 100644 --- a/nostr/client/client.py +++ b/nostr/client/client.py @@ -14,7 +14,7 @@ class NostrClient: if connect: self.connect() - def connect(self): + async def connect(self): for relay in self.relays: self.relay_manager.add_relay(relay) From 403c8f1b05caf595df092cec7b42831a28ddc089 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 5 Jul 2023 09:09:10 +0300 Subject: [PATCH 09/34] fix: thread not dead --- nostr/relay.py | 10 ++++++---- nostr/relay_manager.py | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/nostr/relay.py b/nostr/relay.py index 8b081a3..0583bba 100644 --- a/nostr/relay.py +++ b/nostr/relay.py @@ -102,12 +102,14 @@ class Relay: message = self.queue.get(timeout=1) self.num_sent_events += 1 self.ws.send(message) - except Exception as e: - if self.shutdown: - logger.warning(f"Closing queue worker for '{self.url}'.") - break + except: + pass else: time.sleep(0.1) + + if self.shutdown: + logger.warning(f"Closing queue worker for '{self.url}'.") + break def add_subscription(self, id, filters: Filters): with self.lock: diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py index b2df735..a551253 100644 --- a/nostr/relay_manager.py +++ b/nostr/relay_manager.py @@ -44,12 +44,13 @@ class RelayManager: return relay def remove_relay(self, url: str): - self.threads[url].join(timeout=1) - self.threads.pop(url) - self.queue_threads[url].join(timeout=1) - self.queue_threads.pop(url) self.relays[url].close() self.relays.pop(url) + self.threads[url].join(timeout=5) + self.threads.pop(url) + self.queue_threads[url].join(timeout=5) + self.queue_threads.pop(url) + def add_subscription(self, id: str, filters: Filters): with self._subscriptions_lock: From 9d9fbc01895ec7b940b8e29a2fba850b5905755b Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 5 Jul 2023 09:09:20 +0300 Subject: [PATCH 10/34] chore: code clean-up --- templates/nostrclient/index.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/nostrclient/index.html b/templates/nostrclient/index.html index 82b149e..a0c5999 100644 --- a/templates/nostrclient/index.html +++ b/templates/nostrclient/index.html @@ -240,7 +240,6 @@ 'HH:mm:ss' ) } - console.log('### obj', obj) return obj } @@ -512,7 +511,6 @@ } }, showLogDataDialog: function (data = []) { - console.log('### showLogDataDialog', data) this.logData.data = data.join('\n') this.logData.show = true }, From e6624f76bd0ead9d9f1e2259cfe40d3da7e75622 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Tue, 12 Sep 2023 15:06:28 +0300 Subject: [PATCH 11/34] Performance improvements (#19) * fix: increase the wait time for re-connecting to a relay * fix: blocking sleep * fix: remove blocking sleep * fix: allow multiple filters per request --- nostr/client/client.py | 6 +++--- nostr/relay.py | 5 +++-- nostr/relay_manager.py | 17 ++++++++++------- router.py | 4 ++-- tasks.py | 10 ++++++---- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/nostr/client/client.py b/nostr/client/client.py index e033262..db07a06 100644 --- a/nostr/client/client.py +++ b/nostr/client/client.py @@ -1,4 +1,4 @@ -import time +import asyncio from typing import List from ..relay_manager import RelayManager @@ -21,7 +21,7 @@ class NostrClient: def close(self): self.relay_manager.close_connections() - def subscribe( + async def subscribe( self, callback_events_func=None, callback_notices_func=None, @@ -41,4 +41,4 @@ class NostrClient: if callback_eosenotices_func: callback_eosenotices_func(event_msg) - time.sleep(0.1) + await asyncio.sleep(0.5) diff --git a/nostr/relay.py b/nostr/relay.py index 0583bba..caacba0 100644 --- a/nostr/relay.py +++ b/nostr/relay.py @@ -1,3 +1,4 @@ +import asyncio import json import time from queue import Queue @@ -95,7 +96,7 @@ class Relay: json_str = json.dumps(["REQ", s["id"], s["filters"][0]]) self.publish(json_str) - def queue_worker(self): + async def queue_worker(self): while True: if self.connected: try: @@ -105,7 +106,7 @@ class Relay: except: pass else: - time.sleep(0.1) + await asyncio.sleep(1) if self.shutdown: logger.warning(f"Closing queue worker for '{self.url}'.") diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py index a551253..f639fb0 100644 --- a/nostr/relay_manager.py +++ b/nostr/relay_manager.py @@ -1,4 +1,5 @@ +import asyncio import ssl import threading import time @@ -95,20 +96,22 @@ class RelayManager: ) self.threads[relay.url].start() + def wrap_async_queue_worker(): + asyncio.run(relay.queue_worker()) + self.queue_threads[relay.url] = threading.Thread( - target=relay.queue_worker, + target=wrap_async_queue_worker, name=f"{relay.url}-queue", daemon=True, ) self.queue_threads[relay.url].start() def _restart_relay(self, relay: Relay): - if relay.error_threshold_reached: - time_since_last_error = time.time() - relay.last_error_date - if time_since_last_error < 60 * 60 * 2: # last day - return - relay.error_counter = 0 - relay.error_list = [] + time_since_last_error = time.time() - relay.last_error_date + + min_wait_time = min(60 * relay.error_counter, 60 * 60 * 24) # try at least once a day + if time_since_last_error < min_wait_time: + return logger.info(f"Restarting connection to relay '{relay.url}'") diff --git a/router.py b/router.py index e85653c..86a8f41 100644 --- a/router.py +++ b/router.py @@ -170,13 +170,13 @@ class NostrRouter: subscription_id = json_data[1] subscription_id_rewritten = urlsafe_short_hash() self.original_subscription_ids[subscription_id_rewritten] = subscription_id - fltr = json_data[2] + fltr = json_data[2:] filters = self._marshall_nostr_filters(fltr) nostr.client.relay_manager.add_subscription( subscription_id_rewritten, filters ) - request_rewritten = json.dumps([json_data[0], subscription_id_rewritten, fltr]) + request_rewritten = json.dumps([json_data[0], subscription_id_rewritten] + fltr) self.subscriptions.append(subscription_id_rewritten) nostr.client.relay_manager.publish_message(request_rewritten) diff --git a/tasks.py b/tasks.py index 05057e7..4c316bc 100644 --- a/tasks.py +++ b/tasks.py @@ -66,13 +66,15 @@ async def subscribe_events(): return - t = threading.Thread( - target=nostr.client.subscribe, - args=( + def wrap_async_subscribe(): + asyncio.run(nostr.client.subscribe( callback_events, callback_notices, callback_eose_notices, - ), + )) + + t = threading.Thread( + target=wrap_async_subscribe, name="Nostr-event-subscription", daemon=True, ) From 6f5e9e34589044225b372eed01594f01a17fc24f Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 15 Sep 2023 10:21:38 +0300 Subject: [PATCH 12/34] chore: doc formatting --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1de61ba..02d12b7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # Nostrclient - [LNbits](https://github.com/lnbits/lnbits) extension + For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) `nostrclient` is an always-on extension that can open multiple connections to nostr relays and act as a multiplexer for other clients: You open a single websocket to `nostrclient` which then sends the data to multiple relays. The responses from these relays are then sent back to the client. ![2023-03-08 18 11 07](https://user-images.githubusercontent.com/93376500/225265727-369f0f8a-196e-41df-a0d1-98b50a0228be.jpg) - ### Troubleshoot -The `Test Endpoint` functionality heps the user to check that the `nostrclient` web-socket endpoint works as expected. + +The `Test Endpoint` functionality heps the user to check that the `nostrclient` web-socket endpoint works as expected. The LNbits user can DM itself (or a temp account) from `nostrclient` and verify that the messages are sent and received correctly. https://user-images.githubusercontent.com/2951406/236780745-929c33c2-2502-49be-84a3-db02a7aabc0e.mp4 - From e841183c1022eb50646802f4a907febc6ac691f3 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 25 Sep 2023 10:13:55 +0300 Subject: [PATCH 13/34] fix: add extra checks (#21) * fix: add extra checks * fix: remove redundant try-catch --- router.py | 56 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/router.py b/router.py index 86a8f41..cc0a380 100644 --- a/router.py +++ b/router.py @@ -89,31 +89,41 @@ class NostrRouter: async def _handle_received_subscription_eosenotices(self, s): - s_original = self.original_subscription_ids[s] - event_to_forward = ["EOSE", s_original] - del NostrRouter.received_subscription_eosenotices[s] - - await self.websocket.send_text(json.dumps(event_to_forward)) + try: + if s not in self.original_subscription_ids: + return + s_original = self.original_subscription_ids[s] + event_to_forward = ["EOSE", s_original] + del NostrRouter.received_subscription_eosenotices[s] + + await self.websocket.send_text(json.dumps(event_to_forward)) + except Exception as e: + logger.debug(e) async def _handle_received_subscription_events(self, s): - while len(NostrRouter.received_subscription_events[s]): - my_event = NostrRouter.received_subscription_events[s].pop(0) - # event.to_message() does not include the subscription ID, we have to add it manually - event_json = { - "id": my_event.id, - "pubkey": my_event.public_key, - "created_at": my_event.created_at, - "kind": my_event.kind, - "tags": my_event.tags, - "content": my_event.content, - "sig": my_event.signature, - } + try: + if s not in NostrRouter.received_subscription_events: + return + while len(NostrRouter.received_subscription_events[s]): + my_event = NostrRouter.received_subscription_events[s].pop(0) + # event.to_message() does not include the subscription ID, we have to add it manually + event_json = { + "id": my_event.id, + "pubkey": my_event.public_key, + "created_at": my_event.created_at, + "kind": my_event.kind, + "tags": my_event.tags, + "content": my_event.content, + "sig": my_event.signature, + } - # this reconstructs the original response from the relay - # reconstruct original subscription id - s_original = self.original_subscription_ids[s] - event_to_forward = ["EVENT", s_original, event_json] - await self.websocket.send_text(json.dumps(event_to_forward)) + # this reconstructs the original response from the relay + # reconstruct original subscription id + s_original = self.original_subscription_ids[s] + event_to_forward = ["EVENT", s_original, event_json] + await self.websocket.send_text(json.dumps(event_to_forward)) + except Exception as e: + logger.debug(e) def _handle_notices(self): while len(NostrRouter.received_subscription_notices): @@ -121,7 +131,7 @@ class NostrRouter: # note: we don't send it to the user because we don't know who should receive it logger.info(f"Relay ('{my_event.url}') notice: '{my_event.content}']") nostr.client.relay_manager.handle_notice(my_event) - + def _marshall_nostr_filters(self, data: Union[dict, list]): From d202fe305510f2714c9fb4b1ed2c83869c9e9200 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 26 Sep 2023 13:41:58 +0100 Subject: [PATCH 14/34] allow custom path (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * allow custom path --------- Co-authored-by: dni ⚡ --- __init__.py | 4 +--- config.json | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index ec6f4b4..7f573e7 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,6 @@ import asyncio from typing import List from fastapi import APIRouter -from starlette.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer @@ -15,7 +14,6 @@ db = Database("ext_nostrclient") nostrclient_static_files = [ { "path": "/nostrclient/static", - "app": StaticFiles(directory="lnbits/extensions/nostrclient/static"), "name": "nostrclient_static", } ] @@ -33,7 +31,7 @@ nostr = NostrClient() def nostr_renderer(): - return template_renderer(["lnbits/extensions/nostrclient/templates"]) + return template_renderer(["nostrclient/templates"]) from .tasks import check_relays, init_relays, subscribe_events diff --git a/config.json b/config.json index ce8ae18..d8b886b 100644 --- a/config.json +++ b/config.json @@ -2,5 +2,6 @@ "name": "Nostr Client", "short_description": "Nostr client for extensions", "tile": "/nostrclient/static/images/nostr-bitcoin.png", - "contributors": ["calle"] + "contributors": ["calle"], + "min_lnbits_version": "0.11.0" } From ab185bd2c426b2115dac7d49dde7118411b78e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dni=20=E2=9A=A1?= Date: Tue, 26 Sep 2023 15:39:33 +0200 Subject: [PATCH 15/34] add release workflow (#22) --- .github/workflows/release.yml | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7ec9b48 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,59 @@ +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +jobs: + + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Create github release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ github.ref_name }} + run: | + gh release create "$tag" --generate-notes + + pullrequest: + needs: [release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + token: ${{ secrets.EXT_GITHUB }} + repository: lnbits/lnbits-extensions + path: './lnbits-extensions' + + - name: setup git user + run: | + git config --global user.name "alan" + git config --global user.email "alan@lnbits.com" + + - name: Create pull request in extensions repo + env: + GH_TOKEN: ${{ secrets.EXT_GITHUB }} + repo_name: "${{ github.event.repository.name }}" + tag: "${{ github.ref_name }}" + branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}" + title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}" + body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}" + archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip" + run: | + cd lnbits-extensions + git checkout -b $branch + + # if there is another open PR + git pull origin $branch || echo "branch does not exist" + + sh util.sh update_extension $repo_name $tag + + git add -A + git commit -am "$title" + git push origin $branch + + # check if pr exists before creating it + gh config set pager cat + check=$(gh pr list -H $branch | wc -l) + test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions From 16ae9d15a143e194deb767c556591307a39f1e5d Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Wed, 1 Nov 2023 17:46:42 +0200 Subject: [PATCH 16/34] Stabilize (#24) * refactor: clean-up * refactor: extra logs plus try-catch * refactor: do not use bare `except` * refactor: clean-up redundant fields * chore: pass code checks * chore: code format * refactor: code clean-up * fix: refactoring stuff * refactor: remove un-used file * chore: code clean-up * chore: code clean-up * chore: code-format fix * refactor: remove nostr.client wrapper * refactor: code clean-up * chore: code format * refactor: remove `RelayList` class * refactor: extract smaller methods with try-catch * fix: better exception handling * fix: remove redundant filters * fix: simplify event * chore: code format * fix: code check * fix: code check * fix: simplify `REQ` * fix: more clean-ups * refactor: use simpler method * refactor: re-order and rename * fix: stop logic * fix: subscription close before disconnect * chore: play commit --- __init__.py | 11 +- cbc.py | 26 ---- crud.py | 16 +-- migrations.py | 2 +- models.py | 42 ++---- nostr/__init__.py | 0 nostr/bech32.py | 32 +++-- nostr/client/client.py | 57 ++++++-- nostr/delegation.py | 32 ----- nostr/event.py | 3 +- nostr/filter.py | 134 ----------------- nostr/key.py | 14 +- nostr/message_pool.py | 38 ++--- nostr/relay.py | 165 +++++---------------- nostr/relay_manager.py | 109 ++++++++------ nostr/subscription.py | 10 +- router.py | 178 +++++++++-------------- tasks.py | 70 +++++---- templates/nostrclient/index.html | 238 +++++++++++++++++++++++-------- views_api.py | 62 ++++---- 20 files changed, 522 insertions(+), 717 deletions(-) delete mode 100644 cbc.py delete mode 100644 nostr/__init__.py delete mode 100644 nostr/delegation.py delete mode 100644 nostr/filter.py diff --git a/__init__.py b/__init__.py index 7f573e7..d50998b 100644 --- a/__init__.py +++ b/__init__.py @@ -7,7 +7,7 @@ from lnbits.db import Database from lnbits.helpers import template_renderer from lnbits.tasks import catch_everything_and_restart -from .nostr.client.client import NostrClient as NostrClientLib +from .nostr.client.client import NostrClient db = Database("ext_nostrclient") @@ -22,19 +22,14 @@ nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient scheduled_tasks: List[asyncio.Task] = [] -class NostrClient: - def __init__(self): - self.client: NostrClientLib = NostrClientLib(connect=False) - - -nostr = NostrClient() +nostr_client = NostrClient() def nostr_renderer(): return template_renderer(["nostrclient/templates"]) -from .tasks import check_relays, init_relays, subscribe_events +from .tasks import check_relays, init_relays, subscribe_events # noqa from .views import * # noqa from .views_api import * # noqa diff --git a/cbc.py b/cbc.py deleted file mode 100644 index 0d9e04f..0000000 --- a/cbc.py +++ /dev/null @@ -1,26 +0,0 @@ -from Cryptodome.Cipher import AES - -BLOCK_SIZE = 16 - - -class AESCipher(object): - """This class is compatible with crypto.createCipheriv('aes-256-cbc')""" - - def __init__(self, key=None): - self.key = key - - def pad(self, data): - length = BLOCK_SIZE - (len(data) % BLOCK_SIZE) - return data + (chr(length) * length).encode() - - def unpad(self, data): - return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))] - - def encrypt(self, plain_text): - cipher = AES.new(self.key, AES.MODE_CBC) - b = plain_text.encode("UTF-8") - return cipher.iv, cipher.encrypt(self.pad(b)) - - def decrypt(self, iv, enc_text): - cipher = AES.new(self.key, AES.MODE_CBC, iv=iv) - return self.unpad(cipher.decrypt(enc_text).decode("UTF-8")) diff --git a/crud.py b/crud.py index 780642d..05ca907 100644 --- a/crud.py +++ b/crud.py @@ -1,21 +1,17 @@ -from typing import List, Optional, Union - -import shortuuid - -from lnbits.helpers import urlsafe_short_hash +from typing import List from . import db -from .models import Relay, RelayList +from .models import Relay -async def get_relays() -> RelayList: - row = await db.fetchall("SELECT * FROM nostrclient.relays") - return RelayList(__root__=row) +async def get_relays() -> List[Relay]: + rows = await db.fetchall("SELECT * FROM nostrclient.relays") + return [Relay.from_row(r) for r in rows] async def add_relay(relay: Relay) -> None: await db.execute( - f""" + """ INSERT INTO nostrclient.relays ( id, url, diff --git a/migrations.py b/migrations.py index 5a30e45..73b9ed8 100644 --- a/migrations.py +++ b/migrations.py @@ -3,7 +3,7 @@ async def m001_initial(db): Initial nostrclient table. """ await db.execute( - f""" + """ CREATE TABLE nostrclient.relays ( id TEXT NOT NULL PRIMARY KEY, url TEXT NOT NULL, diff --git a/models.py b/models.py index 88651fc..e08ade3 100644 --- a/models.py +++ b/models.py @@ -1,9 +1,7 @@ -from dataclasses import dataclass -from typing import Dict, List, Optional +from sqlite3 import Row +from typing import List, Optional -from fastapi import Request -from fastapi.param_functions import Query -from pydantic import BaseModel, Field +from pydantic import BaseModel from lnbits.helpers import urlsafe_short_hash @@ -14,7 +12,8 @@ class RelayStatus(BaseModel): error_counter: Optional[int] = 0 error_list: Optional[List] = [] notice_list: Optional[List] = [] - + + class Relay(BaseModel): id: Optional[str] = None url: Optional[str] = None @@ -28,33 +27,9 @@ class Relay(BaseModel): if not self.id: self.id = urlsafe_short_hash() - -class RelayList(BaseModel): - __root__: List[Relay] - - -class Event(BaseModel): - content: str - pubkey: str - created_at: Optional[int] - kind: int - tags: Optional[List[List[str]]] - sig: str - - -class Filter(BaseModel): - ids: Optional[List[str]] - kinds: Optional[List[int]] - authors: Optional[List[str]] - since: Optional[int] - until: Optional[int] - e: Optional[List[str]] = Field(alias="#e") - p: Optional[List[str]] = Field(alias="#p") - limit: Optional[int] - - -class Filters(BaseModel): - __root__: List[Filter] + @classmethod + def from_row(cls, row: Row) -> "Relay": + return cls(**dict(row)) class TestMessage(BaseModel): @@ -62,6 +37,7 @@ class TestMessage(BaseModel): reciever_public_key: str message: str + class TestMessageResponse(BaseModel): private_key: str public_key: str diff --git a/nostr/__init__.py b/nostr/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/nostr/bech32.py b/nostr/bech32.py index 61a92c4..0ae6c80 100644 --- a/nostr/bech32.py +++ b/nostr/bech32.py @@ -26,19 +26,22 @@ from enum import Enum class Encoding(Enum): """Enumeration type to list the various supported encodings.""" + BECH32 = 1 BECH32M = 2 + CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" -BECH32M_CONST = 0x2bc830a3 +BECH32M_CONST = 0x2BC830A3 + def bech32_polymod(values): """Internal function that computes the Bech32 checksum.""" - generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] chk = 1 for value in values: top = chk >> 25 - chk = (chk & 0x1ffffff) << 5 ^ value + chk = (chk & 0x1FFFFFF) << 5 ^ value for i in range(5): chk ^= generator[i] if ((top >> i) & 1) else 0 return chk @@ -58,6 +61,7 @@ def bech32_verify_checksum(hrp, data): return Encoding.BECH32M return None + def bech32_create_checksum(hrp, data, spec): """Compute the checksum values given HRP and data.""" values = bech32_hrp_expand(hrp) + data @@ -69,26 +73,29 @@ def bech32_create_checksum(hrp, data, spec): def bech32_encode(hrp, data, spec): """Compute a Bech32 string given HRP and data values.""" combined = data + bech32_create_checksum(hrp, data, spec) - return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + return hrp + "1" + "".join([CHARSET[d] for d in combined]) + def bech32_decode(bech): """Validate a Bech32/Bech32m string, and determine HRP and data.""" - if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or - (bech.lower() != bech and bech.upper() != bech)): + if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or ( + bech.lower() != bech and bech.upper() != bech + ): return (None, None, None) bech = bech.lower() - pos = bech.rfind('1') + pos = bech.rfind("1") if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: return (None, None, None) - if not all(x in CHARSET for x in bech[pos+1:]): + if not all(x in CHARSET for x in bech[pos + 1 :]): return (None, None, None) hrp = bech[:pos] - data = [CHARSET.find(x) for x in bech[pos+1:]] + data = [CHARSET.find(x) for x in bech[pos + 1 :]] spec = bech32_verify_checksum(hrp, data) if spec is None: return (None, None, None) return (hrp, data[:-6], spec) + def convertbits(data, frombits, tobits, pad=True): """General power-of-2 base conversion.""" acc = 0 @@ -124,7 +131,12 @@ def decode(hrp, addr): return (None, None) if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: return (None, None) - if data[0] == 0 and spec != Encoding.BECH32 or data[0] != 0 and spec != Encoding.BECH32M: + if ( + data[0] == 0 + and spec != Encoding.BECH32 + or data[0] != 0 + and spec != Encoding.BECH32M + ): return (None, None) return (data[0], decoded) diff --git a/nostr/client/client.py b/nostr/client/client.py index db07a06..4624ff3 100644 --- a/nostr/client/client.py +++ b/nostr/client/client.py @@ -1,25 +1,36 @@ import asyncio -from typing import List + +from loguru import logger from ..relay_manager import RelayManager class NostrClient: - relays = [ ] relay_manager = RelayManager() - def __init__(self, relays: List[str] = [], connect=True): - if len(relays): - self.relays = relays - if connect: - self.connect() + def __init__(self): + self.running = True - async def connect(self): - for relay in self.relays: - self.relay_manager.add_relay(relay) + def connect(self, relays): + for relay in relays: + try: + self.relay_manager.add_relay(relay) + except Exception as e: + logger.debug(e) + self.running = True + + def reconnect(self, relays): + self.relay_manager.remove_relays() + self.connect(relays) def close(self): - self.relay_manager.close_connections() + try: + self.relay_manager.close_all_subscriptions() + self.relay_manager.close_connections() + + self.running = False + except Exception as e: + logger.error(e) async def subscribe( self, @@ -27,18 +38,36 @@ class NostrClient: callback_notices_func=None, callback_eosenotices_func=None, ): - while True: + while self.running: + self._check_events(callback_events_func) + self._check_notices(callback_notices_func) + self._check_eos_notices(callback_eosenotices_func) + + await asyncio.sleep(0.2) + + def _check_events(self, callback_events_func=None): + try: while self.relay_manager.message_pool.has_events(): event_msg = self.relay_manager.message_pool.get_event() if callback_events_func: callback_events_func(event_msg) + except Exception as e: + logger.debug(e) + + def _check_notices(self, callback_notices_func=None): + try: while self.relay_manager.message_pool.has_notices(): event_msg = self.relay_manager.message_pool.get_notice() if callback_notices_func: callback_notices_func(event_msg) + except Exception as e: + logger.debug(e) + + def _check_eos_notices(self, callback_eosenotices_func=None): + try: while self.relay_manager.message_pool.has_eose_notices(): event_msg = self.relay_manager.message_pool.get_eose_notice() if callback_eosenotices_func: callback_eosenotices_func(event_msg) - - await asyncio.sleep(0.5) + except Exception as e: + logger.debug(e) diff --git a/nostr/delegation.py b/nostr/delegation.py deleted file mode 100644 index 94801f5..0000000 --- a/nostr/delegation.py +++ /dev/null @@ -1,32 +0,0 @@ -import time -from dataclasses import dataclass - - -@dataclass -class Delegation: - delegator_pubkey: str - delegatee_pubkey: str - event_kind: int - duration_secs: int = 30*24*60 # default to 30 days - signature: str = None # set in PrivateKey.sign_delegation - - @property - def expires(self) -> int: - return int(time.time()) + self.duration_secs - - @property - def conditions(self) -> str: - return f"kind={self.event_kind}&created_at<{self.expires}" - - @property - def delegation_token(self) -> str: - return f"nostr:delegation:{self.delegatee_pubkey}:{self.conditions}" - - def get_tag(self) -> list[str]: - """ Called by Event """ - return [ - "delegation", - self.delegator_pubkey, - self.conditions, - self.signature, - ] diff --git a/nostr/event.py b/nostr/event.py index 65b187d..a7d4f1d 100644 --- a/nostr/event.py +++ b/nostr/event.py @@ -122,6 +122,7 @@ class EncryptedDirectMessage(Event): def id(self) -> str: if self.content is None: raise Exception( - "EncryptedDirectMessage `id` is undefined until its message is encrypted and stored in the `content` field" + "EncryptedDirectMessage `id` is undefined until its" + + " message is encrypted and stored in the `content` field" ) return super().id diff --git a/nostr/filter.py b/nostr/filter.py deleted file mode 100644 index f119079..0000000 --- a/nostr/filter.py +++ /dev/null @@ -1,134 +0,0 @@ -from collections import UserList -from typing import List - -from .event import Event, EventKind - - -class Filter: - """ - NIP-01 filtering. - - Explicitly supports "#e" and "#p" tag filters via `event_refs` and `pubkey_refs`. - - Arbitrary NIP-12 single-letter tag filters are also supported via `add_arbitrary_tag`. - If a particular single-letter tag gains prominence, explicit support should be - added. For example: - # arbitrary tag - filter.add_arbitrary_tag('t', [hashtags]) - - # promoted to explicit support - Filter(hashtag_refs=[hashtags]) - """ - - def __init__( - self, - event_ids: List[str] = None, - kinds: List[EventKind] = None, - authors: List[str] = None, - since: int = None, - until: int = None, - event_refs: List[ - str - ] = None, # the "#e" attr; list of event ids referenced in an "e" tag - pubkey_refs: List[ - str - ] = None, # The "#p" attr; list of pubkeys referenced in a "p" tag - limit: int = None, - ) -> None: - self.event_ids = event_ids - self.kinds = kinds - self.authors = authors - self.since = since - self.until = until - self.event_refs = event_refs - self.pubkey_refs = pubkey_refs - self.limit = limit - - self.tags = {} - if self.event_refs: - self.add_arbitrary_tag("e", self.event_refs) - if self.pubkey_refs: - self.add_arbitrary_tag("p", self.pubkey_refs) - - def add_arbitrary_tag(self, tag: str, values: list): - """ - Filter on any arbitrary tag with explicit handling for NIP-01 and NIP-12 - single-letter tags. - """ - # NIP-01 'e' and 'p' tags and any NIP-12 single-letter tags must be prefixed with "#" - tag_key = tag if len(tag) > 1 else f"#{tag}" - self.tags[tag_key] = values - - def matches(self, event: Event) -> bool: - if self.event_ids is not None and event.id not in self.event_ids: - return False - if self.kinds is not None and event.kind not in self.kinds: - return False - if self.authors is not None and event.public_key not in self.authors: - return False - if self.since is not None and event.created_at < self.since: - return False - if self.until is not None and event.created_at > self.until: - return False - if (self.event_refs is not None or self.pubkey_refs is not None) and len( - event.tags - ) == 0: - return False - - if self.tags: - e_tag_identifiers = set([e_tag[0] for e_tag in event.tags]) - for f_tag, f_tag_values in self.tags.items(): - # Omit any NIP-01 or NIP-12 "#" chars on single-letter tags - f_tag = f_tag.replace("#", "") - - if f_tag not in e_tag_identifiers: - # Event is missing a tag type that we're looking for - return False - - # Multiple values within f_tag_values are treated as OR search; an Event - # needs to match only one. - # Note: an Event could have multiple entries of the same tag type - # (e.g. a reply to multiple people) so we have to check all of them. - match_found = False - for e_tag in event.tags: - if e_tag[0] == f_tag and e_tag[1] in f_tag_values: - match_found = True - break - if not match_found: - return False - - return True - - def to_json_object(self) -> dict: - res = {} - if self.event_ids is not None: - res["ids"] = self.event_ids - if self.kinds is not None: - res["kinds"] = self.kinds - if self.authors is not None: - res["authors"] = self.authors - if self.since is not None: - res["since"] = self.since - if self.until is not None: - res["until"] = self.until - if self.limit is not None: - res["limit"] = self.limit - if self.tags: - res.update(self.tags) - - return res - - -class Filters(UserList): - def __init__(self, initlist: "list[Filter]" = []) -> None: - super().__init__(initlist) - self.data: "list[Filter]" - - def match(self, event: Event): - for filter in self.data: - if filter.matches(event): - return True - return False - - def to_json_array(self) -> list: - return [filter.to_json_object() for filter in self.data] diff --git a/nostr/key.py b/nostr/key.py index 8089e11..3803650 100644 --- a/nostr/key.py +++ b/nostr/key.py @@ -1,6 +1,5 @@ import base64 import secrets -from hashlib import sha256 import secp256k1 from cffi import FFI @@ -8,7 +7,6 @@ from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from . import bech32 -from .delegation import Delegation from .event import EncryptedDirectMessage, Event, EventKind @@ -37,7 +35,7 @@ class PublicKey: class PrivateKey: def __init__(self, raw_secret: bytes = None) -> None: - if not raw_secret is None: + if raw_secret is not None: self.raw_secret = raw_secret else: self.raw_secret = secrets.token_bytes(32) @@ -79,7 +77,10 @@ class PrivateKey: encryptor = cipher.encryptor() encrypted_message = encryptor.update(padded_data) + encryptor.finalize() - return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + return ( + f"{base64.b64encode(encrypted_message).decode()}" + + f"?iv={base64.b64encode(iv).decode()}" + ) def encrypt_dm(self, dm: EncryptedDirectMessage) -> None: dm.content = self.encrypt_message( @@ -116,11 +117,6 @@ class PrivateKey: event.public_key = self.public_key.hex() event.signature = self.sign_message_hash(bytes.fromhex(event.id)) - def sign_delegation(self, delegation: Delegation) -> None: - delegation.signature = self.sign_message_hash( - sha256(delegation.delegation_token.encode()).digest() - ) - def __eq__(self, other): return self.raw_secret == other.raw_secret diff --git a/nostr/message_pool.py b/nostr/message_pool.py index 02f7fd4..a3e6c5f 100644 --- a/nostr/message_pool.py +++ b/nostr/message_pool.py @@ -2,13 +2,15 @@ import json from queue import Queue from threading import Lock -from .event import Event from .message_type import RelayMessageType class EventMessage: - def __init__(self, event: Event, subscription_id: str, url: str) -> None: + def __init__( + self, event: str, event_id: str, subscription_id: str, url: str + ) -> None: self.event = event + self.event_id = event_id self.subscription_id = subscription_id self.url = url @@ -59,18 +61,16 @@ class MessagePool: message_type = message_json[0] if message_type == RelayMessageType.EVENT: subscription_id = message_json[1] - e = message_json[2] - event = Event( - e["content"], - e["pubkey"], - e["created_at"], - e["kind"], - e["tags"], - e["sig"], - ) + event = message_json[2] + if "id" not in event: + return + event_id = event["id"] + with self.lock: - if not f"{subscription_id}_{event.id}" in self._unique_events: - self._accept_event(EventMessage(event, subscription_id, url)) + if f"{subscription_id}_{event_id}" not in self._unique_events: + self._accept_event( + EventMessage(json.dumps(event), event_id, subscription_id, url) + ) elif message_type == RelayMessageType.NOTICE: self.notices.put(NoticeMessage(message_json[1], url)) elif message_type == RelayMessageType.END_OF_STORED_EVENTS: @@ -78,10 +78,12 @@ class MessagePool: def _accept_event(self, event_message: EventMessage): """ - Event uniqueness is considered per `subscription_id`. - The `subscription_id` is rewritten to be unique and it is the same accross relays. - The same event can come from different subscriptions (from the same client or from different ones). - Clients that have joined later should receive older events. + Event uniqueness is considered per `subscription_id`. The `subscription_id` is + rewritten to be unique and it is the same accross relays. The same event can + come from different subscriptions (from the same client or from different ones). + Clients that have joined later should receive older events. """ self.events.put(event_message) - self._unique_events.add(f"{event_message.subscription_id}_{event_message.event.id}") \ No newline at end of file + self._unique_events.add( + f"{event_message.subscription_id}_{event_message.event_id}" + ) diff --git a/nostr/relay.py b/nostr/relay.py index caacba0..b576cfa 100644 --- a/nostr/relay.py +++ b/nostr/relay.py @@ -2,43 +2,23 @@ import asyncio import json import time from queue import Queue -from threading import Lock from typing import List from loguru import logger from websocket import WebSocketApp -from .event import Event -from .filter import Filters from .message_pool import MessagePool -from .message_type import RelayMessageType from .subscription import Subscription -class RelayPolicy: - def __init__(self, should_read: bool = True, should_write: bool = True) -> None: - self.should_read = should_read - self.should_write = should_write - - def to_json_object(self) -> dict[str, bool]: - return {"read": self.should_read, "write": self.should_write} - - class Relay: - def __init__( - self, - url: str, - policy: RelayPolicy, - message_pool: MessagePool, - subscriptions: dict[str, Subscription] = {}, - ) -> None: + def __init__(self, url: str, message_pool: MessagePool) -> None: self.url = url - self.policy = policy self.message_pool = message_pool - self.subscriptions = subscriptions self.connected: bool = False self.reconnect: bool = True self.shutdown: bool = False + self.error_counter: int = 0 self.error_threshold: int = 100 self.error_list: List[str] = [] @@ -47,12 +27,10 @@ class Relay: self.num_received_events: int = 0 self.num_sent_events: int = 0 self.num_subscriptions: int = 0 - self.ssl_options: dict = {} - self.proxy: dict = {} - self.lock = Lock() + self.queue = Queue() - def connect(self, ssl_options: dict = None, proxy: dict = None): + def connect(self): self.ws = WebSocketApp( self.url, on_open=self._on_open, @@ -62,19 +40,14 @@ class Relay: on_ping=self._on_ping, on_pong=self._on_pong, ) - self.ssl_options = ssl_options - self.proxy = proxy if not self.connected: - self.ws.run_forever( - sslopt=ssl_options, - http_proxy_host=None if proxy is None else proxy.get("host"), - http_proxy_port=None if proxy is None else proxy.get("port"), - proxy_type=None if proxy is None else proxy.get("type"), - ping_interval=5, - ) + self.ws.run_forever(ping_interval=10) def close(self): - self.ws.close() + try: + self.ws.close() + except Exception as e: + logger.warning(f"[Relay: {self.url}] Failed to close websocket: {e}") self.connected = False self.shutdown = True @@ -90,10 +63,9 @@ class Relay: def publish(self, message: str): self.queue.put(message) - def publish_subscriptions(self): - for _, subscription in self.subscriptions.items(): - s = subscription.to_json_object() - json_str = json.dumps(["REQ", s["id"], s["filters"][0]]) + def publish_subscriptions(self, subscriptions: List[Subscription] = []): + for s in subscriptions: + json_str = json.dumps(["REQ", s.id] + s.filters) self.publish(json_str) async def queue_worker(self): @@ -103,55 +75,44 @@ class Relay: message = self.queue.get(timeout=1) self.num_sent_events += 1 self.ws.send(message) - except: + except Exception as _: pass else: await asyncio.sleep(1) - - if self.shutdown: - logger.warning(f"Closing queue worker for '{self.url}'.") - break - def add_subscription(self, id, filters: Filters): - with self.lock: - self.subscriptions[id] = Subscription(id, filters) + if self.shutdown: + logger.warning(f"[Relay: {self.url}] Closing queue worker.") + return def close_subscription(self, id: str) -> None: - with self.lock: - self.subscriptions.pop(id) + try: self.publish(json.dumps(["CLOSE", id])) - - def to_json_object(self) -> dict: - return { - "url": self.url, - "policy": self.policy.to_json_object(), - "subscriptions": [ - subscription.to_json_object() - for subscription in self.subscriptions.values() - ], - } + except Exception as e: + logger.debug(f"[Relay: {self.url}] Failed to close subscription: {e}") def add_notice(self, notice: str): - self.notice_list = ([notice] + self.notice_list)[:20] + self.notice_list = [notice] + self.notice_list def _on_open(self, _): - logger.info(f"Connected to relay: '{self.url}'.") + logger.info(f"[Relay: {self.url}] Connected.") self.connected = True - + self.shutdown = False + def _on_close(self, _, status_code, message): - logger.warning(f"Connection to relay {self.url} closed. Status: '{status_code}'. Message: '{message}'.") + logger.warning( + f"[Relay: {self.url}] Connection closed." + + f" Status: '{status_code}'. Message: '{message}'." + ) self.close() def _on_message(self, _, message: str): - if self._is_valid_message(message): - self.num_received_events += 1 - self.message_pool.add_message(message, self.url) + self.num_received_events += 1 + self.message_pool.add_message(message, self.url) def _on_error(self, _, error): - logger.warning(f"Relay error: '{str(error)}'") + logger.warning(f"[Relay: {self.url}] Error: '{str(error)}'") self._append_error_message(str(error)) - self.connected = False - self.error_counter += 1 + self.close() def _on_ping(self, *_): return @@ -159,65 +120,7 @@ class Relay: def _on_pong(self, *_): return - def _is_valid_message(self, message: str) -> bool: - message = message.strip("\n") - if not message or message[0] != "[" or message[-1] != "]": - return False - - message_json = json.loads(message) - message_type = message_json[0] - - if not RelayMessageType.is_valid(message_type): - return False - - if message_type == RelayMessageType.EVENT: - return self._is_valid_event_message(message_json) - - if message_type == RelayMessageType.COMMAND_RESULT: - return self._is_valid_command_result_message(message, message_json) - - return True - - def _is_valid_event_message(self, message_json): - if not len(message_json) == 3: - return False - - subscription_id = message_json[1] - with self.lock: - if subscription_id not in self.subscriptions: - return False - - e = message_json[2] - event = Event( - e["content"], - e["pubkey"], - e["created_at"], - e["kind"], - e["tags"], - e["sig"], - ) - if not event.verify(): - return False - - with self.lock: - subscription = self.subscriptions[subscription_id] - - if subscription.filters and not subscription.filters.match(event): - return False - - return True - - def _is_valid_command_result_message(self, message, message_json): - if not len(message_json) < 3: - return False - - if message_json[2] != True: - logger.warning(f"Relay '{self.url}' negative command result: '{message}'") - self._append_error_message(message) - return False - - return True - def _append_error_message(self, message): - self.error_list = ([message] + self.error_list)[:20] - self.last_error_date = int(time.time()) \ No newline at end of file + self.error_counter += 1 + self.error_list = [message] + self.error_list + self.last_error_date = int(time.time()) diff --git a/nostr/relay_manager.py b/nostr/relay_manager.py index f639fb0..ff7ca9c 100644 --- a/nostr/relay_manager.py +++ b/nostr/relay_manager.py @@ -1,21 +1,15 @@ - import asyncio -import ssl import threading import time +from typing import List from loguru import logger -from .filter import Filters from .message_pool import MessagePool, NoticeMessage -from .relay import Relay, RelayPolicy +from .relay import Relay from .subscription import Subscription -class RelayException(Exception): - pass - - class RelayManager: def __init__(self) -> None: self.relays: dict[str, Relay] = {} @@ -25,72 +19,97 @@ class RelayManager: self._cached_subscriptions: dict[str, Subscription] = {} self._subscriptions_lock = threading.Lock() - def add_relay(self, url: str, read: bool = True, write: bool = True) -> Relay: + def add_relay(self, url: str) -> Relay: if url in list(self.relays.keys()): - return - - with self._subscriptions_lock: - subscriptions = self._cached_subscriptions.copy() + logger.debug(f"Relay '{url}' already present.") + return self.relays[url] - policy = RelayPolicy(read, write) - relay = Relay(url, policy, self.message_pool, subscriptions) + relay = Relay(url, self.message_pool) self.relays[url] = relay - self._open_connection( - relay, - {"cert_reqs": ssl.CERT_NONE} - ) # NOTE: This disables ssl certificate verification + self._open_connection(relay) - relay.publish_subscriptions() + relay.publish_subscriptions(list(self._cached_subscriptions.values())) return relay def remove_relay(self, url: str): - self.relays[url].close() - self.relays.pop(url) - self.threads[url].join(timeout=5) - self.threads.pop(url) - self.queue_threads[url].join(timeout=5) - self.queue_threads.pop(url) - + try: + self.relays[url].close() + except Exception as e: + logger.debug(e) - def add_subscription(self, id: str, filters: Filters): + if url in self.relays: + self.relays.pop(url) + + try: + self.threads[url].join(timeout=5) + except Exception as e: + logger.debug(e) + + if url in self.threads: + self.threads.pop(url) + + try: + self.queue_threads[url].join(timeout=5) + except Exception as e: + logger.debug(e) + + if url in self.queue_threads: + self.queue_threads.pop(url) + + def remove_relays(self): + relay_urls = list(self.relays.keys()) + for url in relay_urls: + self.remove_relay(url) + + def add_subscription(self, id: str, filters: List[str]): + s = Subscription(id, filters) with self._subscriptions_lock: - self._cached_subscriptions[id] = Subscription(id, filters) + self._cached_subscriptions[id] = s for relay in self.relays.values(): - relay.add_subscription(id, filters) + relay.publish_subscriptions([s]) def close_subscription(self, id: str): - with self._subscriptions_lock: - self._cached_subscriptions.pop(id) + try: + with self._subscriptions_lock: + if id in self._cached_subscriptions: + self._cached_subscriptions.pop(id) - for relay in self.relays.values(): - relay.close_subscription(id) + for relay in self.relays.values(): + relay.close_subscription(id) + except Exception as e: + logger.debug(e) + + def close_subscriptions(self, subscriptions: List[str]): + for id in subscriptions: + self.close_subscription(id) + + def close_all_subscriptions(self): + all_subscriptions = list(self._cached_subscriptions.keys()) + self.close_subscriptions(all_subscriptions) def check_and_restart_relays(self): stopped_relays = [r for r in self.relays.values() if r.shutdown] for relay in stopped_relays: self._restart_relay(relay) - def close_connections(self): for relay in self.relays.values(): relay.close() def publish_message(self, message: str): for relay in self.relays.values(): - if relay.policy.should_write: - relay.publish(message) + relay.publish(message) def handle_notice(self, notice: NoticeMessage): relay = next((r for r in self.relays.values() if r.url == notice.url)) if relay: relay.add_notice(notice.content) - def _open_connection(self, relay: Relay, ssl_options: dict = None, proxy: dict = None): + def _open_connection(self, relay: Relay): self.threads[relay.url] = threading.Thread( target=relay.connect, - args=(ssl_options, proxy), name=f"{relay.url}-thread", daemon=True, ) @@ -98,7 +117,7 @@ class RelayManager: def wrap_async_queue_worker(): asyncio.run(relay.queue_worker()) - + self.queue_threads[relay.url] = threading.Thread( target=wrap_async_queue_worker, name=f"{relay.url}-queue", @@ -108,14 +127,16 @@ class RelayManager: def _restart_relay(self, relay: Relay): time_since_last_error = time.time() - relay.last_error_date - - min_wait_time = min(60 * relay.error_counter, 60 * 60 * 24) # try at least once a day + + min_wait_time = min( + 60 * relay.error_counter, 60 * 60 + ) # try at least once an hour if time_since_last_error < min_wait_time: return - + logger.info(f"Restarting connection to relay '{relay.url}'") self.remove_relay(relay.url) new_relay = self.add_relay(relay.url) new_relay.error_counter = relay.error_counter - new_relay.error_list = relay.error_list \ No newline at end of file + new_relay.error_list = relay.error_list diff --git a/nostr/subscription.py b/nostr/subscription.py index 76da0af..a75c1a1 100644 --- a/nostr/subscription.py +++ b/nostr/subscription.py @@ -1,13 +1,7 @@ -from .filter import Filters +from typing import List class Subscription: - def __init__(self, id: str, filters: Filters=None) -> None: + def __init__(self, id: str, filters: List[str] = None) -> None: self.id = id self.filters = filters - - def to_json_object(self): - return { - "id": self.id, - "filters": self.filters.to_json_array() - } diff --git a/router.py b/router.py index cc0a380..e6ccdef 100644 --- a/router.py +++ b/router.py @@ -1,42 +1,61 @@ import asyncio import json -from typing import List, Union +from typing import Dict, List -from fastapi import WebSocketDisconnect +from fastapi import WebSocket, WebSocketDisconnect from loguru import logger from lnbits.helpers import urlsafe_short_hash -from . import nostr -from .models import Event, Filter -from .nostr.filter import Filter as NostrFilter -from .nostr.filter import Filters as NostrFilters -from .nostr.message_pool import EndOfStoredEventsMessage, NoticeMessage +from . import nostr_client +from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage class NostrRouter: - - received_subscription_events: dict[str, list[Event]] = {} + received_subscription_events: dict[str, List[EventMessage]] = {} received_subscription_notices: list[NoticeMessage] = [] received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage] = {} - def __init__(self, websocket): - self.subscriptions: List[str] = [] + def __init__(self, websocket: WebSocket): self.connected: bool = True - self.websocket = websocket + self.websocket: WebSocket = websocket self.tasks: List[asyncio.Task] = [] - self.original_subscription_ids = {} + self.original_subscription_ids: Dict[str, str] = {} - async def client_to_nostr(self): - """Receives requests / data from the client and forwards it to relays. If the - request was a subscription/filter, registers it with the nostr client lib. - Remembers the subscription id so we can send back responses from the relay to this - client in `nostr_to_client`""" - while True: + @property + def subscriptions(self) -> List[str]: + return list(self.original_subscription_ids.keys()) + + def start(self): + self.connected = True + self.tasks.append(asyncio.create_task(self._client_to_nostr())) + self.tasks.append(asyncio.create_task(self._nostr_to_client())) + + async def stop(self): + nostr_client.relay_manager.close_subscriptions(self.subscriptions) + self.connected = False + + for t in self.tasks: + try: + t.cancel() + except Exception as _: + pass + + try: + await self.websocket.close() + except Exception as _: + pass + + async def _client_to_nostr(self): + """ + Receives requests / data from the client and forwards it to relays. + """ + while self.connected: try: json_str = await self.websocket.receive_text() - except WebSocketDisconnect: - self.connected = False + except WebSocketDisconnect as e: + logger.debug(e) + await self.stop() break try: @@ -44,15 +63,9 @@ class NostrRouter: except Exception as e: logger.debug(f"Failed to handle client message: '{str(e)}'.") - - async def nostr_to_client(self): - """Sends responses from relays back to the client. Polls the subscriptions of this client - stored in `my_subscriptions`. Then gets all responses for this subscription id from `received_subscription_events` which - is filled in tasks.py. Takes one response after the other and relays it back to the client. Reconstructs - the reponse manually because the nostr client lib we're using can't do it. Reconstructs the original subscription id - that we had previously rewritten in order to avoid collisions when multiple clients use the same id. - """ - while True and self.connected: + async def _nostr_to_client(self): + """Sends responses from relays back to the client.""" + while self.connected: try: await self._handle_subscriptions() self._handle_notices() @@ -61,24 +74,6 @@ class NostrRouter: await asyncio.sleep(0.1) - async def start(self): - self.tasks.append(asyncio.create_task(self.client_to_nostr())) - self.tasks.append(asyncio.create_task(self.nostr_to_client())) - - async def stop(self): - for t in self.tasks: - try: - t.cancel() - except: - pass - - for s in self.subscriptions: - try: - nostr.client.relay_manager.close_subscription(s) - except: - pass - self.connected = False - async def _handle_subscriptions(self): for s in self.subscriptions: if s in NostrRouter.received_subscription_events: @@ -86,8 +81,6 @@ class NostrRouter: if s in NostrRouter.received_subscription_eosenotices: await self._handle_received_subscription_eosenotices(s) - - async def _handle_received_subscription_eosenotices(self, s): try: if s not in self.original_subscription_ids: @@ -95,7 +88,7 @@ class NostrRouter: s_original = self.original_subscription_ids[s] event_to_forward = ["EOSE", s_original] del NostrRouter.received_subscription_eosenotices[s] - + await self.websocket.send_text(json.dumps(event_to_forward)) except Exception as e: logger.debug(e) @@ -104,97 +97,62 @@ class NostrRouter: try: if s not in NostrRouter.received_subscription_events: return + while len(NostrRouter.received_subscription_events[s]): - my_event = NostrRouter.received_subscription_events[s].pop(0) - # event.to_message() does not include the subscription ID, we have to add it manually - event_json = { - "id": my_event.id, - "pubkey": my_event.public_key, - "created_at": my_event.created_at, - "kind": my_event.kind, - "tags": my_event.tags, - "content": my_event.content, - "sig": my_event.signature, - } + event_message = NostrRouter.received_subscription_events[s].pop(0) + event_json = event_message.event # this reconstructs the original response from the relay # reconstruct original subscription id s_original = self.original_subscription_ids[s] - event_to_forward = ["EVENT", s_original, event_json] - await self.websocket.send_text(json.dumps(event_to_forward)) + event_to_forward = f"""["EVENT", "{s_original}", {event_json}]""" + await self.websocket.send_text(event_to_forward) except Exception as e: - logger.debug(e) + logger.debug(e) # there are 2900 errors here def _handle_notices(self): while len(NostrRouter.received_subscription_notices): my_event = NostrRouter.received_subscription_notices.pop(0) - # note: we don't send it to the user because we don't know who should receive it - logger.info(f"Relay ('{my_event.url}') notice: '{my_event.content}']") - nostr.client.relay_manager.handle_notice(my_event) - - - - def _marshall_nostr_filters(self, data: Union[dict, list]): - filters = data if isinstance(data, list) else [data] - filters = [Filter.parse_obj(f) for f in filters] - filter_list: list[NostrFilter] = [] - for filter in filters: - filter_list.append( - NostrFilter( - event_ids=filter.ids, # type: ignore - kinds=filter.kinds, # type: ignore - authors=filter.authors, # type: ignore - since=filter.since, # type: ignore - until=filter.until, # type: ignore - event_refs=filter.e, # type: ignore - pubkey_refs=filter.p, # type: ignore - limit=filter.limit, # type: ignore - ) - ) - return NostrFilters(filter_list) + logger.info(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']") + # Note: we don't send it to the user because + # we don't know who should receive it + nostr_client.relay_manager.handle_notice(my_event) async def _handle_client_to_nostr(self, json_str): - """Parses a (string) request from a client. If it is a subscription (REQ) or a CLOSE, it will - register the subscription in the nostr client library that we're using so we can - receive the callbacks on it later. Will rewrite the subscription id since we expect - multiple clients to use the router and want to avoid subscription id collisions - """ - json_data = json.loads(json_str) - assert len(json_data) - + assert len(json_data), "Bad JSON array" if json_data[0] == "REQ": self._handle_client_req(json_data) return - + if json_data[0] == "CLOSE": self._handle_client_close(json_data[1]) return if json_data[0] == "EVENT": - nostr.client.relay_manager.publish_message(json_str) + nostr_client.relay_manager.publish_message(json_str) return def _handle_client_req(self, json_data): subscription_id = json_data[1] subscription_id_rewritten = urlsafe_short_hash() self.original_subscription_ids[subscription_id_rewritten] = subscription_id - fltr = json_data[2:] - filters = self._marshall_nostr_filters(fltr) + filters = json_data[2:] - nostr.client.relay_manager.add_subscription( - subscription_id_rewritten, filters - ) - request_rewritten = json.dumps([json_data[0], subscription_id_rewritten] + fltr) - - self.subscriptions.append(subscription_id_rewritten) - nostr.client.relay_manager.publish_message(request_rewritten) + nostr_client.relay_manager.add_subscription(subscription_id_rewritten, filters) def _handle_client_close(self, subscription_id): - subscription_id_rewritten = next((k for k, v in self.original_subscription_ids.items() if v == subscription_id), None) + subscription_id_rewritten = next( + ( + k + for k, v in self.original_subscription_ids.items() + if v == subscription_id + ), + None, + ) if subscription_id_rewritten: self.original_subscription_ids.pop(subscription_id_rewritten) - nostr.client.relay_manager.close_subscription(subscription_id_rewritten) + nostr_client.relay_manager.close_subscription(subscription_id_rewritten) else: logger.debug(f"Failed to unsubscribe from '{subscription_id}.'") diff --git a/tasks.py b/tasks.py index 4c316bc..69aa33c 100644 --- a/tasks.py +++ b/tasks.py @@ -3,75 +3,69 @@ import threading from loguru import logger -from . import nostr +from . import nostr_client from .crud import get_relays from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage -from .router import NostrRouter, nostr +from .router import NostrRouter async def init_relays(): - # reinitialize the entire client - nostr.__init__() # get relays from db relays = await get_relays() # set relays and connect to them - nostr.client.relays = list(set([r.url for r in relays.__root__ if r.url])) - await nostr.client.connect() + valid_relays = list(set([r.url for r in relays if r.url])) + + nostr_client.reconnect(valid_relays) async def check_relays(): - """ Check relays that have been disconnected """ + """Check relays that have been disconnected""" while True: try: await asyncio.sleep(20) - nostr.client.relay_manager.check_and_restart_relays() + nostr_client.relay_manager.check_and_restart_relays() except Exception as e: logger.warning(f"Cannot restart relays: '{str(e)}'.") - + async def subscribe_events(): - while not any([r.connected for r in nostr.client.relay_manager.relays.values()]): + while not any([r.connected for r in nostr_client.relay_manager.relays.values()]): await asyncio.sleep(2) def callback_events(eventMessage: EventMessage): - if eventMessage.subscription_id in NostrRouter.received_subscription_events: - # do not add duplicate events (by event id) - if eventMessage.event.id in set( - [ - e.id - for e in NostrRouter.received_subscription_events[eventMessage.subscription_id] - ] - ): - return + sub_id = eventMessage.subscription_id + if sub_id not in NostrRouter.received_subscription_events: + NostrRouter.received_subscription_events[sub_id] = [eventMessage] + return - NostrRouter.received_subscription_events[eventMessage.subscription_id].append( - eventMessage.event - ) - else: - NostrRouter.received_subscription_events[eventMessage.subscription_id] = [ - eventMessage.event - ] - return + # do not add duplicate events (by event id) + ids = set( + [e.event_id for e in NostrRouter.received_subscription_events[sub_id]] + ) + if eventMessage.event_id in ids: + return + + NostrRouter.received_subscription_events[sub_id].append(eventMessage) def callback_notices(noticeMessage: NoticeMessage): if noticeMessage not in NostrRouter.received_subscription_notices: NostrRouter.received_subscription_notices.append(noticeMessage) - return def callback_eose_notices(eventMessage: EndOfStoredEventsMessage): - if eventMessage.subscription_id not in NostrRouter.received_subscription_eosenotices: - NostrRouter.received_subscription_eosenotices[ - eventMessage.subscription_id - ] = eventMessage + sub_id = eventMessage.subscription_id + if sub_id in NostrRouter.received_subscription_eosenotices: + return - return + NostrRouter.received_subscription_eosenotices[sub_id] = eventMessage def wrap_async_subscribe(): - asyncio.run(nostr.client.subscribe( - callback_events, - callback_notices, - callback_eose_notices, - )) + asyncio.run( + nostr_client.subscribe( + callback_events, + callback_notices, + callback_eose_notices, + ) + ) t = threading.Thread( target=wrap_async_subscribe, diff --git a/templates/nostrclient/index.html b/templates/nostrclient/index.html index a0c5999..db0f98e 100644 --- a/templates/nostrclient/index.html +++ b/templates/nostrclient/index.html @@ -6,13 +6,30 @@
- +
- - - + + @@ -29,18 +46,36 @@
Nostrclient
- +
- +