feat: code quality (#31)

* feat: code quality
This commit is contained in:
dni ⚡ 2024-08-30 13:07:33 +02:00 committed by GitHub
parent d656d41b90
commit a8eb139360
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 3192 additions and 237 deletions

34
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: CI
on:
push:
branches:
- main
pull_request:
jobs:
lint:
uses: lnbits/lnbits/.github/workflows/lint.yml@dev
tests:
runs-on: ubuntu-latest
needs: [lint]
strategy:
matrix:
python-version: ['3.9', '3.10']
steps:
- uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: ${{ matrix.python-version }}
- name: Run pytest
uses: pavelzw/pytest-action@v2
env:
LNBITS_BACKEND_WALLET_CLASS: FakeWallet
PYTHONUNBUFFERED: 1
DEBUG: true
with:
verbose: true
job-summary: true
emoji: false
click-to-expand: true
custom-pytest: poetry run pytest
report-title: 'test (${{ matrix.python-version }})'

View file

@ -1,10 +1,9 @@
on: on:
push: push:
tags: tags:
- "v[0-9]+.[0-9]+.[0-9]+" - 'v[0-9]+.[0-9]+.[0-9]+'
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -34,12 +33,12 @@ jobs:
- name: Create pull request in extensions repo - name: Create pull request in extensions repo
env: env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }} GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}" repo_name: '${{ github.event.repository.name }}'
tag: "${{ github.ref_name }}" tag: '${{ github.ref_name }}'
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}" branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ 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 }}" 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" archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: | run: |
cd lnbits-extensions cd lnbits-extensions
git checkout -b $branch git checkout -b $branch

24
.gitignore vendored
View file

@ -1,24 +1,4 @@
.DS_Store
._*
__pycache__ __pycache__
*.py[cod] node_modules
*$py.class
.mypy_cache .mypy_cache
.vscode .venv
*-lock.json
*.egg
*.egg-info
.coverage
.pytest_cache
.webassets-cache
htmlcov
test-reports
tests/data/*.sqlite3
*.swo
*.swp
*.pyo
*.pyc
*.env

12
.prettierrc Normal file
View file

@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"bracketSameLine": false,
"bracketSpacing": false
}

47
Makefile Normal file
View file

@ -0,0 +1,47 @@
all: format check
format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier
prettier:
poetry run ./node_modules/.bin/prettier --write .
pyright:
poetry run ./node_modules/.bin/pyright
mypy:
poetry run mypy .
black:
poetry run black .
ruff:
poetry run ruff check . --fix
checkruff:
poetry run ruff check .
checkprettier:
poetry run ./node_modules/.bin/prettier --check .
checkblack:
poetry run black --check .
checkeditorconfig:
editorconfig-checker
test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
poetry run pytest
install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall"
poetry run pre-commit install
pre-commit:
poetry run pre-commit run --all-files
checkbundle:
@echo "skipping checkbundle"

View file

@ -1,17 +1,13 @@
import asyncio import asyncio
from typing import List
from fastapi import APIRouter from fastapi import APIRouter
from loguru import logger from loguru import logger
from lnbits.db import Database from .crud import db
from lnbits.helpers import template_renderer from .nostr_client import all_routers, nostr_client
from lnbits.tasks import create_permanent_unique_task from .tasks import check_relays, init_relays, subscribe_events
from .views import nostrclient_generic_router
from .nostr.client.client import NostrClient from .views_api import nostrclient_api_router
from .router import NostrRouter
db = Database("ext_nostrclient")
nostrclient_static_files = [ nostrclient_static_files = [
{ {
@ -21,23 +17,11 @@ nostrclient_static_files = [
] ]
nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"]) nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"])
nostrclient_ext.include_router(nostrclient_generic_router)
nostr_client: NostrClient = NostrClient() nostrclient_ext.include_router(nostrclient_api_router)
# we keep this in
all_routers: list[NostrRouter] = []
scheduled_tasks: list[asyncio.Task] = [] scheduled_tasks: list[asyncio.Task] = []
def nostr_renderer():
return template_renderer(["nostrclient/templates"])
from .tasks import check_relays, init_relays, subscribe_events # noqa
from .views import * # noqa
from .views_api import * # noqa
async def nostrclient_stop(): async def nostrclient_stop():
for task in scheduled_tasks: for task in scheduled_tasks:
try: try:
@ -56,7 +40,20 @@ async def nostrclient_stop():
def nostrclient_start(): def nostrclient_start():
from lnbits.tasks import create_permanent_unique_task
task1 = create_permanent_unique_task("ext_nostrclient_init_relays", init_relays) task1 = create_permanent_unique_task("ext_nostrclient_init_relays", init_relays)
task2 = create_permanent_unique_task("ext_nostrclient_subscrive_events", subscribe_events) task2 = create_permanent_unique_task(
"ext_nostrclient_subscrive_events", subscribe_events
)
task3 = create_permanent_unique_task("ext_nostrclient_check_relays", check_relays) task3 = create_permanent_unique_task("ext_nostrclient_check_relays", check_relays)
scheduled_tasks.extend([task1, task2, task3]) scheduled_tasks.extend([task1, task2, task3])
__all__ = [
"db",
"nostrclient_ext",
"nostrclient_static_files",
"nostrclient_stop",
"nostrclient_start",
]

26
crud.py
View file

@ -1,12 +1,14 @@
from typing import List, Optional
import json import json
from typing import Optional
from lnbits.db import Database
from . import db
from .models import Config, Relay from .models import Config, Relay
db = Database("ext_nostrclient")
async def get_relays() -> List[Relay]:
async def get_relays() -> list[Relay]:
rows = await db.fetchall("SELECT * FROM nostrclient.relays") rows = await db.fetchall("SELECT * FROM nostrclient.relays")
return [Relay.from_row(r) for r in rows] return [Relay.from_row(r) for r in rows]
@ -37,27 +39,21 @@ async def create_config() -> Config:
INSERT INTO nostrclient.config (json_data) INSERT INTO nostrclient.config (json_data)
VALUES (?) VALUES (?)
""", """,
(json.dumps(config.dict())), (json.dumps(config.dict()),),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
) )
row = await db.fetchone("SELECT json_data FROM nostrclient.config", ())
return json.loads(row[0], object_hook=lambda d: Config(**d)) return json.loads(row[0], object_hook=lambda d: Config(**d))
async def update_config(config: Config) -> Optional[Config]: async def update_config(config: Config) -> Optional[Config]:
await db.execute( await db.execute(
"""UPDATE nostrclient.config SET json_data = ?""", """UPDATE nostrclient.config SET json_data = ?""",
(json.dumps(config.dict())), (json.dumps(config.dict()),),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
) )
row = await db.fetchone("SELECT json_data FROM nostrclient.config", ())
return json.loads(row[0], object_hook=lambda d: Config(**d)) return json.loads(row[0], object_hook=lambda d: Config(**d))
async def get_config() -> Optional[Config]: async def get_config() -> Optional[Config]:
row = await db.fetchone( row = await db.fetchone("SELECT json_data FROM nostrclient.config", ())
"SELECT json_data FROM nostrclient.config", ()
)
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None

View file

@ -1,9 +1,8 @@
from sqlite3 import Row from sqlite3 import Row
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from pydantic import BaseModel
class RelayStatus(BaseModel): class RelayStatus(BaseModel):

View file

@ -124,27 +124,29 @@ def decode(hrp, addr):
hrpgot, data, spec = bech32_decode(addr) hrpgot, data, spec = bech32_decode(addr)
if hrpgot != hrp: if hrpgot != hrp:
return (None, None) return (None, None)
decoded = convertbits(data[1:], 5, 8, False) decoded = convertbits(data[1:], 5, 8, False) # type: ignore
if decoded is None or len(decoded) < 2 or len(decoded) > 40: if decoded is None or len(decoded) < 2 or len(decoded) > 40:
return (None, None) return (None, None)
if data[0] > 16: if data[0] > 16: # type: ignore
return (None, None) return (None, None)
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: # type: ignore
return (None, None) return (None, None)
if ( if (
data[0] == 0 data[0] == 0 # type: ignore
and spec != Encoding.BECH32 and spec != Encoding.BECH32
or data[0] != 0 or data[0] != 0 # type: ignore
and spec != Encoding.BECH32M and spec != Encoding.BECH32M
): ):
return (None, None) return (None, None)
return (data[0], decoded) return (data[0], decoded) # type: ignore
def encode(hrp, witver, witprog): def encode(hrp, witver, witprog):
"""Encode a segwit address.""" """Encode a segwit address."""
spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec) wit_prog = convertbits(witprog, 8, 5)
assert wit_prog
ret = bech32_encode(hrp, [witver, *wit_prog], spec)
if decode(hrp, ret) == (None, None): if decode(hrp, ret) == (None, None):
return None return None
return ret return ret

View file

@ -3,7 +3,7 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import IntEnum from enum import IntEnum
from hashlib import sha256 from hashlib import sha256
from typing import List from typing import Optional
from secp256k1 import PublicKey from secp256k1 import PublicKey
@ -21,14 +21,14 @@ class EventKind(IntEnum):
@dataclass @dataclass
class Event: class Event:
content: str = None content: Optional[str] = None
public_key: str = None public_key: Optional[str] = None
created_at: int = None created_at: Optional[int] = None
kind: int = EventKind.TEXT_NOTE kind: int = EventKind.TEXT_NOTE
tags: List[List[str]] = field( tags: list[list[str]] = field(
default_factory=list default_factory=list
) # Dataclasses require special handling when the default value is a mutable type ) # Dataclasses require special handling when the default value is a mutable type
signature: str = None signature: Optional[str] = None
def __post_init__(self): def __post_init__(self):
if self.content is not None and not isinstance(self.content, str): if self.content is not None and not isinstance(self.content, str):
@ -40,7 +40,7 @@ class Event:
@staticmethod @staticmethod
def serialize( def serialize(
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str public_key: str, created_at: int, kind: int, tags: list[list[str]], content: str
) -> bytes: ) -> bytes:
data = [0, public_key, created_at, kind, tags, content] data = [0, public_key, created_at, kind, tags, content]
data_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) data_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
@ -48,7 +48,7 @@ class Event:
@staticmethod @staticmethod
def compute_id( def compute_id(
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str public_key: str, created_at: int, kind: int, tags: list[list[str]], content: str
): ):
return sha256( return sha256(
Event.serialize(public_key, created_at, kind, tags, content) Event.serialize(public_key, created_at, kind, tags, content)
@ -57,6 +57,9 @@ class Event:
@property @property
def id(self) -> str: def id(self) -> str:
# Always recompute the id to reflect the up-to-date state of the Event # Always recompute the id to reflect the up-to-date state of the Event
assert self.public_key
assert self.created_at
assert self.content
return Event.compute_id( return Event.compute_id(
self.public_key, self.created_at, self.kind, self.tags, self.content self.public_key, self.created_at, self.kind, self.tags, self.content
) )
@ -70,6 +73,8 @@ class Event:
self.tags.append(["e", event_id]) self.tags.append(["e", event_id])
def verify(self) -> bool: def verify(self) -> bool:
assert self.public_key
assert self.signature
pub_key = PublicKey( pub_key = PublicKey(
bytes.fromhex("02" + self.public_key), True bytes.fromhex("02" + self.public_key), True
) # add 02 for schnorr (bip340) ) # add 02 for schnorr (bip340)
@ -96,9 +101,9 @@ class Event:
@dataclass @dataclass
class EncryptedDirectMessage(Event): class EncryptedDirectMessage(Event):
recipient_pubkey: str = None recipient_pubkey: Optional[str] = None
cleartext_content: str = None cleartext_content: Optional[str] = None
reference_event_id: str = None reference_event_id: Optional[str] = None
def __post_init__(self): def __post_init__(self):
if self.content is not None: if self.content is not None:

View file

@ -1,12 +1,13 @@
import base64 import base64
import secrets import secrets
from typing import Optional
import secp256k1 import secp256k1
from cffi import FFI from cffi import FFI
from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from . import bech32 from .bech32 import Encoding, bech32_decode, bech32_encode, convertbits
from .event import EncryptedDirectMessage, Event, EventKind from .event import EncryptedDirectMessage, Event, EventKind
@ -15,44 +16,51 @@ class PublicKey:
self.raw_bytes = raw_bytes self.raw_bytes = raw_bytes
def bech32(self) -> str: def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_bytes, 8, 5) converted_bits = convertbits(self.raw_bytes, 8, 5)
return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32) return bech32_encode("npub", converted_bits, Encoding.BECH32)
def hex(self) -> str: def hex(self) -> str:
return self.raw_bytes.hex() return self.raw_bytes.hex()
def verify_signed_message_hash(self, hash: str, sig: str) -> bool: def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool:
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True) pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True) return pk.schnorr_verify(
bytes.fromhex(message_hash), bytes.fromhex(sig), None, True
)
@classmethod @classmethod
def from_npub(cls, npub: str): def from_npub(cls, npub: str):
"""Load a PublicKey from its bech32/npub form""" """Load a PublicKey from its bech32/npub form"""
hrp, data, spec = bech32.bech32_decode(npub) hrp, data, spec = bech32_decode(npub)
raw_public_key = bech32.convertbits(data, 5, 8)[:-1] raw_data = convertbits(data, 5, 8)
assert raw_data
raw_public_key = raw_data[:-1]
return cls(bytes(raw_public_key)) return cls(bytes(raw_public_key))
class PrivateKey: class PrivateKey:
def __init__(self, raw_secret: bytes = None) -> None: def __init__(self, raw_secret: Optional[bytes] = None) -> None:
if raw_secret is not None: if raw_secret is not None:
self.raw_secret = raw_secret self.raw_secret = raw_secret
else: else:
self.raw_secret = secrets.token_bytes(32) self.raw_secret = secrets.token_bytes(32)
sk = secp256k1.PrivateKey(self.raw_secret) sk = secp256k1.PrivateKey(self.raw_secret)
assert sk.pubkey
self.public_key = PublicKey(sk.pubkey.serialize()[1:]) self.public_key = PublicKey(sk.pubkey.serialize()[1:])
@classmethod @classmethod
def from_nsec(cls, nsec: str): def from_nsec(cls, nsec: str):
"""Load a PrivateKey from its bech32/nsec form""" """Load a PrivateKey from its bech32/nsec form"""
hrp, data, spec = bech32.bech32_decode(nsec) hrp, data, spec = bech32_decode(nsec)
raw_secret = bech32.convertbits(data, 5, 8)[:-1] raw_data = convertbits(data, 5, 8)
assert raw_data
raw_secret = raw_data[:-1]
return cls(bytes(raw_secret)) return cls(bytes(raw_secret))
def bech32(self) -> str: def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_secret, 8, 5) converted_bits = convertbits(self.raw_secret, 8, 5)
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32) return bech32_encode("nsec", converted_bits, Encoding.BECH32)
def hex(self) -> str: def hex(self) -> str:
return self.raw_secret.hex() return self.raw_secret.hex()
@ -83,6 +91,8 @@ class PrivateKey:
) )
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None: def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
assert dm.cleartext_content
assert dm.recipient_pubkey
dm.content = self.encrypt_message( dm.content = self.encrypt_message(
message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey
) )
@ -105,14 +115,14 @@ class PrivateKey:
return unpadded_data.decode() return unpadded_data.decode()
def sign_message_hash(self, hash: bytes) -> str: def sign_message_hash(self, message_hash: bytes) -> str:
sk = secp256k1.PrivateKey(self.raw_secret) sk = secp256k1.PrivateKey(self.raw_secret)
sig = sk.schnorr_sign(hash, None, raw=True) sig = sk.schnorr_sign(message_hash, None, raw=True)
return sig.hex() return sig.hex()
def sign_event(self, event: Event) -> None: def sign_event(self, event: Event) -> None:
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None: if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
self.encrypt_dm(event) self.encrypt_dm(event) # type: ignore
if event.public_key is None: if event.public_key is None:
event.public_key = self.public_key.hex() event.public_key = self.public_key.hex()
event.signature = self.sign_message_hash(bytes.fromhex(event.id)) event.signature = self.sign_message_hash(bytes.fromhex(event.id))
@ -121,7 +131,9 @@ class PrivateKey:
return self.raw_secret == other.raw_secret return self.raw_secret == other.raw_secret
def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey: def mine_vanity_key(
prefix: Optional[str] = None, suffix: Optional[str] = None
) -> PrivateKey:
if prefix is None and suffix is None: if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")

View file

@ -2,7 +2,6 @@ import asyncio
import json import json
import time import time
from queue import Queue from queue import Queue
from typing import List
from loguru import logger from loguru import logger
from websocket import WebSocketApp from websocket import WebSocketApp
@ -21,14 +20,14 @@ class Relay:
self.error_counter: int = 0 self.error_counter: int = 0
self.error_threshold: int = 100 self.error_threshold: int = 100
self.error_list: List[str] = [] self.error_list: list[str] = []
self.notice_list: List[str] = [] self.notice_list: list[str] = []
self.last_error_date: int = 0 self.last_error_date: int = 0
self.num_received_events: int = 0 self.num_received_events: int = 0
self.num_sent_events: int = 0 self.num_sent_events: int = 0
self.num_subscriptions: int = 0 self.num_subscriptions: int = 0
self.queue = Queue() self.queue: Queue = Queue()
def connect(self): def connect(self):
self.ws = WebSocketApp( self.ws = WebSocketApp(
@ -63,9 +62,10 @@ class Relay:
def publish(self, message: str): def publish(self, message: str):
self.queue.put(message) self.queue.put(message)
def publish_subscriptions(self, subscriptions: List[Subscription] = []): def publish_subscriptions(self, subscriptions: list[Subscription]):
for s in subscriptions: for s in subscriptions:
json_str = json.dumps(["REQ", s.id] + s.filters) assert s.filters
json_str = json.dumps(["REQ", s.id, *s.filters])
self.publish(json_str) self.publish(json_str)
async def queue_worker(self): async def queue_worker(self):
@ -84,14 +84,14 @@ class Relay:
logger.warning(f"[Relay: {self.url}] Closing queue worker.") logger.warning(f"[Relay: {self.url}] Closing queue worker.")
return return
def close_subscription(self, id: str) -> None: def close_subscription(self, sub_id: str) -> None:
try: try:
self.publish(json.dumps(["CLOSE", id])) self.publish(json.dumps(["CLOSE", sub_id]))
except Exception as e: except Exception as e:
logger.debug(f"[Relay: {self.url}] Failed to close subscription: {e}") logger.debug(f"[Relay: {self.url}] Failed to close subscription: {e}")
def add_notice(self, notice: str): def add_notice(self, notice: str):
self.notice_list = [notice] + self.notice_list self.notice_list = [notice, *self.notice_list]
def _on_open(self, _): def _on_open(self, _):
logger.info(f"[Relay: {self.url}] Connected.") logger.info(f"[Relay: {self.url}] Connected.")
@ -110,7 +110,7 @@ class Relay:
self.message_pool.add_message(message, self.url) self.message_pool.add_message(message, self.url)
def _on_error(self, _, error): def _on_error(self, _, error):
logger.warning(f"[Relay: {self.url}] Error: '{str(error)}'") logger.warning(f"[Relay: {self.url}] Error: '{error!s}'")
self._append_error_message(str(error)) self._append_error_message(str(error))
self.close() self.close()
@ -122,5 +122,5 @@ class Relay:
def _append_error_message(self, message): def _append_error_message(self, message):
self.error_counter += 1 self.error_counter += 1
self.error_list = [message] + self.error_list self.error_list = [message, *self.error_list]
self.last_error_date = int(time.time()) self.last_error_date = int(time.time())

View file

@ -1,7 +1,7 @@
from typing import List from typing import Optional
class Subscription: class Subscription:
def __init__(self, id: str, filters: List[str] = None) -> None: def __init__(self, id: str, filters: Optional[list[str]] = None) -> None:
self.id = id self.id = id
self.filters = filters self.filters = filters

5
nostr_client.py Normal file
View file

@ -0,0 +1,5 @@
from .nostr.client.client import NostrClient
from .router import NostrRouter
nostr_client: NostrClient = NostrClient()
all_routers: list[NostrRouter] = []

59
package-lock.json generated Normal file
View file

@ -0,0 +1,59 @@
{
"name": "nostrclient",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nostrclient",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pyright": {
"version": "1.1.374",
"resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.374.tgz",
"integrity": "sha512-ISbC1YnYDYrEatoKKjfaA5uFIp0ddC/xw9aSlN/EkmwupXUMVn41Jl+G6wHEjRhC+n4abHZeGpEvxCUus/K9dA==",
"bin": {
"pyright": "index.js",
"pyright-langserver": "langserver.index.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}

15
package.json Normal file
View file

@ -0,0 +1,15 @@
{
"name": "nostrclient",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"prettier": "^3.2.5",
"pyright": "^1.1.358"
}
}

2531
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

101
pyproject.toml Normal file
View file

@ -0,0 +1,101 @@
[tool.poetry]
name = "lnbits-nostrclient"
version = "0.0.0"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies]
python = "^3.10 | ^3.9"
lnbits = "*"
[tool.poetry.group.dev.dependencies]
black = "^24.3.0"
pytest-asyncio = "^0.21.0"
pytest = "^7.3.2"
mypy = "^1.5.1"
pre-commit = "^3.2.2"
ruff = "^0.3.2"
types-cffi = "^1.16.0.20240331"
pytest-md = "^0.2.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
exclude = "(nostr/*)"
[[tool.mypy.overrides]]
module = [
"nostr.*",
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
"secp256k1.*",
"websocket.*",
]
follow_imports = "skip"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
exclude = [
"nostr",
]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

View file

@ -3,18 +3,17 @@ import json
from typing import Dict, List from typing import Dict, List
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from loguru import logger
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from . import nostr_client from . import nostr_client
from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage
class NostrRouter: class NostrRouter:
received_subscription_events: dict[str, List[EventMessage]] = {} received_subscription_events: dict[str, List[EventMessage]]
received_subscription_notices: list[NoticeMessage] = [] received_subscription_notices: list[NoticeMessage]
received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage] = {} received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage]
def __init__(self, websocket: WebSocket): def __init__(self, websocket: WebSocket):
self.connected: bool = True self.connected: bool = True
@ -61,7 +60,7 @@ class NostrRouter:
try: try:
await self._handle_client_to_nostr(json_str) await self._handle_client_to_nostr(json_str)
except Exception as e: except Exception as e:
logger.debug(f"Failed to handle client message: '{str(e)}'.") logger.debug(f"Failed to handle client message: '{e!s}'.")
async def _nostr_to_client(self): async def _nostr_to_client(self):
"""Sends responses from relays back to the client.""" """Sends responses from relays back to the client."""
@ -70,10 +69,9 @@ class NostrRouter:
await self._handle_subscriptions() await self._handle_subscriptions()
self._handle_notices() self._handle_notices()
except Exception as e: except Exception as e:
logger.debug(f"Failed to handle response for client: '{str(e)}'.") logger.debug(f"Failed to handle response for client: '{e!s}'.")
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
async def _handle_subscriptions(self): async def _handle_subscriptions(self):
for s in self.subscriptions: for s in self.subscriptions:
if s in NostrRouter.received_subscription_events: if s in NostrRouter.received_subscription_events:
@ -155,6 +153,11 @@ class NostrRouter:
if subscription_id_rewritten: if subscription_id_rewritten:
self.original_subscription_ids.pop(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)
logger.info(f"Unsubscribe from '{subscription_id_rewritten}'. Original id: '{subscription_id}.'") logger.info(
f"""
Unsubscribe from '{subscription_id_rewritten}'.
Original id: '{subscription_id}.'
"""
)
else: else:
logger.info(f"Failed to unsubscribe from '{subscription_id}.'") logger.info(f"Failed to unsubscribe from '{subscription_id}.'")

View file

@ -13,7 +13,7 @@ async def init_relays():
# get relays from db # get relays from db
relays = await get_relays() relays = await get_relays()
# set relays and connect to them # set relays and connect to them
valid_relays = list(set([r.url for r in relays if r.url])) valid_relays = [r.url for r in relays if r.url]
nostr_client.reconnect(valid_relays) nostr_client.reconnect(valid_relays)
@ -25,38 +25,36 @@ async def check_relays():
await asyncio.sleep(20) await asyncio.sleep(20)
nostr_client.relay_manager.check_and_restart_relays() nostr_client.relay_manager.check_and_restart_relays()
except Exception as e: except Exception as e:
logger.warning(f"Cannot restart relays: '{str(e)}'.") logger.warning(f"Cannot restart relays: '{e!s}'.")
async def subscribe_events(): async def subscribe_events():
while not any([r.connected for r in nostr_client.relay_manager.relays.values()]): while not [r.connected for r in nostr_client.relay_manager.relays.values()]:
await asyncio.sleep(2) await asyncio.sleep(2)
def callback_events(eventMessage: EventMessage): def callback_events(event_message: EventMessage):
sub_id = eventMessage.subscription_id sub_id = event_message.subscription_id
if sub_id not in NostrRouter.received_subscription_events: if sub_id not in NostrRouter.received_subscription_events:
NostrRouter.received_subscription_events[sub_id] = [eventMessage] NostrRouter.received_subscription_events[sub_id] = [event_message]
return return
# do not add duplicate events (by event id) # do not add duplicate events (by event id)
ids = set( ids = [e.event_id for e in NostrRouter.received_subscription_events[sub_id]]
[e.event_id for e in NostrRouter.received_subscription_events[sub_id]] if event_message.event_id in ids:
)
if eventMessage.event_id in ids:
return return
NostrRouter.received_subscription_events[sub_id].append(eventMessage) NostrRouter.received_subscription_events[sub_id].append(event_message)
def callback_notices(noticeMessage: NoticeMessage): def callback_notices(notice_message: NoticeMessage):
if noticeMessage not in NostrRouter.received_subscription_notices: if notice_message not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(noticeMessage) NostrRouter.received_subscription_notices.append(notice_message)
def callback_eose_notices(eventMessage: EndOfStoredEventsMessage): def callback_eose_notices(event_message: EndOfStoredEventsMessage):
sub_id = eventMessage.subscription_id sub_id = event_message.subscription_id
if sub_id in NostrRouter.received_subscription_eosenotices: if sub_id in NostrRouter.received_subscription_eosenotices:
return return
NostrRouter.received_subscription_eosenotices[sub_id] = eventMessage NostrRouter.received_subscription_eosenotices[sub_id] = event_message
def wrap_async_subscribe(): def wrap_async_subscribe():
asyncio.run( asyncio.run(

View file

@ -6,21 +6,44 @@
<q-form @submit="addRelay"> <q-form @submit="addRelay">
<div class="row"> <div class="row">
<div class="col-12 col-md-7 q-pa-md"> <div class="col-12 col-md-7 q-pa-md">
<q-input outlined v-model="relayToAdd" dense filled label="Relay URL"></q-input> <q-input
outlined
v-model="relayToAdd"
dense
filled
label="Relay URL"
></q-input>
</div> </div>
<div class="col-6 col-md-3 q-pa-md"> <div class="col-6 col-md-3 q-pa-md">
<q-btn-dropdown unelevated split color="primary" class="float-left" type="submit" label="Add Relay"> <q-btn-dropdown
<q-item v-for="relay in predefinedRelays" :key="relay" @click="addCustomRelay(relay)" clickable unelevated
v-close-popup> split
color="primary"
class="float-left"
type="submit"
label="Add Relay"
>
<q-item
v-for="relay in predefinedRelays"
:key="relay"
@click="addCustomRelay(relay)"
clickable
v-close-popup
>
<q-item-section> <q-item-section>
<q-item-label><span v-text="relay"></span></q-item-label> <q-item-label><span v-text="relay"></span></q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-btn-dropdown> </q-btn-dropdown>
</div> </div>
<div class="col-6 col-md-2 q-pa-md"> <div class="col-6 col-md-2 q-pa-md">
<q-btn unelevated @click="config.showDialog = true" color="primary" icon="settings" <q-btn
class="float-right"></q-btn> unelevated
@click="config.showDialog = true"
color="primary"
icon="settings"
class="float-right"
></q-btn>
</div> </div>
</div> </div>
</q-form> </q-form>
@ -32,18 +55,36 @@
<h5 class="text-subtitle1 q-my-none">Nostrclient</h5> <h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search"> <q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append> <template v-slot:append>
<q-icon name="search"></q-icon> <q-icon name="search"></q-icon>
</template> </template>
</q-input> </q-input>
</div> </div>
</div> </div>
<q-table flat dense :data="nostrrelayLinks" row-key="id" :columns="relayTable.columns" <q-table
:pagination.sync="relayTable.pagination" :filter="filter"> flat
dense
:data="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
:filter="filter"
>
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width> <q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div> <div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div> <div v-else>{{ col.label }}</div>
</q-th> </q-th>
@ -52,7 +93,12 @@
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width> <q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'connected'"> <div v-if="col.name == 'connected'">
<div v-if="col.value">🟢</div> <div v-if="col.value">🟢</div>
<div v-else>🔴</div> <div v-else>🔴</div>
@ -61,17 +107,29 @@
<div> <div>
⬆️ <span v-text="col.value.sentEvents"></span> ⬇️ ⬆️ <span v-text="col.value.sentEvents"></span> ⬇️
<span v-text="col.value.receveidEvents"></span> <span v-text="col.value.receveidEvents"></span>
<span @click="showLogDataDialog(col.value.errorList)" class="cursor-pointer"> <span
@click="showLogDataDialog(col.value.errorList)"
class="cursor-pointer"
>
⚠️ <span v-text="col.value.errorCount"> </span> ⚠️ <span v-text="col.value.errorCount"> </span>
</span> </span>
<span @click="showLogDataDialog(col.value.noticeList)" class="cursor-pointer float-right"> <span
@click="showLogDataDialog(col.value.noticeList)"
class="cursor-pointer float-right"
>
</span> </span>
</div> </div>
</div> </div>
<div v-else-if="col.name == 'delete'"> <div v-else-if="col.name == 'delete'">
<q-btn flat dense size="md" @click="showDeleteRelayDialog(props.row.url)" icon="cancel" <q-btn
color="pink"></q-btn> flat
dense
size="md"
@click="showDeleteRelayDialog(props.row.url)"
icon="cancel"
color="pink"
></q-btn>
</div> </div>
<div v-else> <div v-else>
<div>{{ col.value }}</div> <div>{{ col.value }}</div>
@ -87,15 +145,32 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="text-weight-bold"> <div class="text-weight-bold">
<q-btn flat dense size="0.6rem" class="q-px-none q-mx-none" color="grey" icon="content_copy" <q-btn
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"><q-tooltip>Copy address</q-tooltip></q-btn> flat
dense
size="0.6rem"
class="q-px-none q-mx-none"
color="grey"
icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"
><q-tooltip>Copy address</q-tooltip></q-btn
>
Your endpoint: Your endpoint:
<q-badge outline class="q-ml-sm text-subtitle2" :label="`wss://${host}/nostrclient/api/v1/relay`" /> <q-badge
outline
class="q-ml-sm text-subtitle2"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</div> </div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-expansion-item group="advanced" icon="settings" label="Test this endpoint" @click="toggleTestPanel"> <q-expansion-item
group="advanced"
icon="settings"
label="Test this endpoint"
@click="toggleTestPanel"
>
<q-separator></q-separator> <q-separator></q-separator>
<q-card-section> <q-card-section>
<div class="row"> <div class="row">
@ -103,8 +178,13 @@
<span>Sender Private Key:</span> <span>Sender Private Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.senderPrivateKey" dense filled <q-input
label="Private Key (optional)"></q-input> outlined
v-model="testData.senderPrivateKey"
dense
filled
label="Private Key (optional)"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-sm q-mb-lg"> <div class="row q-mt-sm q-mb-lg">
@ -113,7 +193,8 @@
<q-badge color="yellow" text-color="black"> <q-badge color="yellow" text-color="black">
<span> <span>
No not use your real private key! Leave empty for a randomly No not use your real private key! Leave empty for a randomly
generated key.</span> generated key.</span
>
</q-badge> </q-badge>
</div> </div>
</div> </div>
@ -122,7 +203,13 @@
<span>Sender Public Key:</span> <span>Sender Public Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.senderPublicKey" dense readonly filled></q-input> <q-input
outlined
v-model="testData.senderPublicKey"
dense
readonly
filled
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -130,8 +217,15 @@
<span>Test Message:</span> <span>Test Message:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.message" dense filled rows="3" type="textarea" <q-input
label="Test Message *"></q-input> outlined
v-model="testData.message"
dense
filled
rows="3"
type="textarea"
label="Test Message *"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -139,22 +233,35 @@
<span>Receiver Public Key:</span> <span>Receiver Public Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.recieverPublicKey" dense filled <q-input
label="Public Key (hex or npub) *"></q-input> outlined
v-model="testData.recieverPublicKey"
dense
filled
label="Public Key (hex or npub) *"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-sm q-mb-lg"> <div class="row q-mt-sm q-mb-lg">
<div class="col-3"></div> <div class="col-3"></div>
<div class="col-9"> <div class="col-9">
<q-badge color="yellow" text-color="black"> <q-badge color="yellow" text-color="black">
<span>This is the recipient of the message. Field required.</span> <span
>This is the recipient of the message. Field required.</span
>
</q-badge> </q-badge>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<q-btn :disabled="!testData.recieverPublicKey || !testData.message" @click="sendTestMessage" unelevated <q-btn
color="primary" class="float-right">Send Message</q-btn> :disabled="!testData.recieverPublicKey || !testData.message"
@click="sendTestMessage"
unelevated
color="primary"
class="float-right"
>Send Message</q-btn
>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -166,7 +273,14 @@
<span>Sent Data:</span> <span>Sent Data:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.sentData" dense filled rows="5" type="textarea"></q-input> <q-input
outlined
v-model="testData.sentData"
dense
filled
rows="5"
type="textarea"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -174,7 +288,14 @@
<span>Received Data:</span> <span>Received Data:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.receivedData" dense filled rows="5" type="textarea"></q-input> <q-input
outlined
v-model="testData.receivedData"
dense
filled
rows="5"
type="textarea"
></q-input>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -193,8 +314,12 @@
</p> </p>
<p> <p>
<q-badge outline class="q-ml-sm text-subtitle2" color="primary" <q-badge
:label="`wss://${host}/nostrclient/api/v1/relay`" /> outline
class="q-ml-sm text-subtitle2"
color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</p> </p>
Only Admin users can manage this extension. Only Admin users can manage this extension.
<q-card-section></q-card-section> <q-card-section></q-card-section>
@ -204,7 +329,15 @@
<q-dialog v-model="logData.show" position="top"> <q-dialog v-model="logData.show" position="top">
<q-card class="q-pa-lg q-pt-xl"> <q-card class="q-pa-lg q-pt-xl">
<q-input filled dense v-model.trim="logData.data" type="textarea" rows="25" cols="200" label="Log Data"></q-input> <q-input
filled
dense
v-model.trim="logData.data"
type="textarea"
rows="25"
cols="200"
label="Log Data"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
@ -215,12 +348,22 @@
<q-dialog v-model="config.showDialog" position="top"> <q-dialog v-model="config.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="updateConfig" class="q-gutter-md"> <q-form @submit="updateConfig" class="q-gutter-md">
<q-toggle label="Expose Private Websocket" color="secodary" v-model="config.data.private_ws"></q-toggle> <q-toggle
label="Expose Private Websocket"
color="secodary"
v-model="config.data.private_ws"
></q-toggle>
<br /> <br />
<q-toggle label="Expose Public Websocket" color="secodary" v-model="config.data.public_ws"></q-toggle> <q-toggle
label="Expose Public Websocket"
color="secodary"
v-model="config.data.public_ws"
></q-toggle>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Update</q-btn> <q-btn unelevated color="primary" type="submit">Update</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div> </div>
</q-form> </q-form>
</q-card> </q-card>
@ -272,7 +415,7 @@
}, },
config: { config: {
showDialog: false, showDialog: false,
data: {}, data: {}
}, },
testData: { testData: {
show: false, show: false,
@ -371,7 +514,7 @@
'POST', 'POST',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id, '/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ url: this.relayToAdd } {url: this.relayToAdd}
) )
.then(function (response) { .then(function (response) {
console.log('response:', response) console.log('response:', response)
@ -403,7 +546,7 @@
'DELETE', 'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id, '/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ url: url } {url: url}
) )
.then(response => { .then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url) const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
@ -418,12 +561,11 @@
}, },
getConfig: async function () { getConfig: async function () {
try { try {
const { data } = await LNbits.api const {data} = await LNbits.api.request(
.request( 'GET',
'GET', '/nostrclient/api/v1/config',
'/nostrclient/api/v1/config', this.g.user.wallets[0].adminkey
this.g.user.wallets[0].adminkey )
)
this.config.data = data this.config.data = data
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
@ -431,14 +573,13 @@
}, },
updateConfig: async function () { updateConfig: async function () {
try { try {
const { data } = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/config', '/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
this.config.data this.config.data
) )
this.config.data = data this.config.data = data
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
@ -481,7 +622,7 @@
}, },
sendTestMessage: async function () { sendTestMessage: async function () {
try { try {
const { data } = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id, '/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
@ -502,7 +643,7 @@
const subscription = JSON.stringify([ const subscription = JSON.stringify([
'REQ', 'REQ',
'test-dms', 'test-dms',
{ kinds: [4], '#p': [event.pubkey] } {kinds: [4], '#p': [event.pubkey]}
]) ])
this.testData.wsConnection.send(subscription) this.testData.wsConnection.send(subscription)
} catch (error) { } catch (error) {

0
tests/__init__.py Normal file
View file

11
tests/test_init.py Normal file
View file

@ -0,0 +1,11 @@
import pytest
from fastapi import APIRouter
from .. import nostrclient_ext
# just import router and add it to a test router
@pytest.mark.asyncio
async def test_router():
router = APIRouter()
router.include_router(nostrclient_ext)

View file

@ -1,16 +1,20 @@
from fastapi import Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_admin from lnbits.decorators import check_admin
from lnbits.helpers import template_renderer
from . import nostr_renderer, nostrclient_ext
templates = Jinja2Templates(directory="templates") templates = Jinja2Templates(directory="templates")
nostrclient_generic_router = APIRouter()
@nostrclient_ext.get("/", response_class=HTMLResponse)
def nostr_renderer():
return template_renderer(["nostrclient/templates"])
@nostrclient_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_admin)): async def index(request: Request, user: User = Depends(check_admin)):
return nostr_renderer().TemplateResponse( return nostr_renderer().TemplateResponse(
"nostrclient/index.html", {"request": request, "user": user.dict()} "nostrclient/index.html", {"request": request, "user": user.dict()}

View file

@ -1,24 +1,30 @@
import asyncio import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import List
from fastapi import Depends, WebSocket
from loguru import logger
from starlette.exceptions import HTTPException
from fastapi import APIRouter, Depends, HTTPException, WebSocket
from lnbits.decorators import check_admin from lnbits.decorators import check_admin
from lnbits.helpers import decrypt_internal_message, urlsafe_short_hash from lnbits.helpers import decrypt_internal_message, urlsafe_short_hash
from loguru import logger
from . import all_routers, nostr_client, nostrclient_ext from .crud import (
from .crud import add_relay, create_config, delete_relay, get_config, get_relays, update_config add_relay,
create_config,
delete_relay,
get_config,
get_relays,
update_config,
)
from .helpers import normalize_public_key from .helpers import normalize_public_key
from .models import Config, Relay, TestMessage, TestMessageResponse from .models import Config, Relay, RelayStatus, TestMessage, TestMessageResponse
from .nostr.key import EncryptedDirectMessage, PrivateKey from .nostr.key import EncryptedDirectMessage, PrivateKey
from .nostr_client import all_routers, nostr_client
from .router import NostrRouter from .router import NostrRouter
nostrclient_api_router = APIRouter()
@nostrclient_ext.get("/api/v1/relays", dependencies=[Depends(check_admin)])
async def api_get_relays() -> List[Relay]: @nostrclient_api_router.get("/api/v1/relays", dependencies=[Depends(check_admin)])
async def api_get_relays() -> list[Relay]:
relays = [] relays = []
for url, r in nostr_client.relay_manager.relays.items(): for url, r in nostr_client.relay_manager.relays.items():
relay_id = urlsafe_short_hash() relay_id = urlsafe_short_hash()
@ -27,13 +33,13 @@ async def api_get_relays() -> List[Relay]:
id=relay_id, id=relay_id,
url=url, url=url,
connected=r.connected, connected=r.connected,
status={ status=RelayStatus(
"num_sent_events": r.num_sent_events, num_sent_events=r.num_sent_events,
"num_received_events": r.num_received_events, num_received_events=r.num_received_events,
"error_counter": r.error_counter, error_counter=r.error_counter,
"error_list": r.error_list, error_list=r.error_list,
"notice_list": r.notice_list, notice_list=r.notice_list,
}, ),
ping=r.ping, ping=r.ping,
active=True, active=True,
) )
@ -41,10 +47,10 @@ async def api_get_relays() -> List[Relay]:
return relays return relays
@nostrclient_ext.post( @nostrclient_api_router.post(
"/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] "/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
) )
async def api_add_relay(relay: Relay) -> List[Relay]: async def api_add_relay(relay: Relay) -> list[Relay]:
if not relay.url: if not relay.url:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Relay url not provided." status_code=HTTPStatus.BAD_REQUEST, detail="Relay url not provided."
@ -62,7 +68,7 @@ async def api_add_relay(relay: Relay) -> List[Relay]:
return await get_relays() return await get_relays()
@nostrclient_ext.delete( @nostrclient_api_router.delete(
"/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] "/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
) )
async def api_delete_relay(relay: Relay) -> None: async def api_delete_relay(relay: Relay) -> None:
@ -75,7 +81,7 @@ async def api_delete_relay(relay: Relay) -> None:
await delete_relay(relay) await delete_relay(relay)
@nostrclient_ext.put( @nostrclient_api_router.put(
"/api/v1/relay/test", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] "/api/v1/relay/test", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
) )
async def api_test_endpoint(data: TestMessage) -> TestMessageResponse: async def api_test_endpoint(data: TestMessage) -> TestMessageResponse:
@ -99,36 +105,36 @@ async def api_test_endpoint(data: TestMessage) -> TestMessageResponse:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex), detail=str(ex),
) ) from ex
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot generate test event", detail="Cannot generate test event",
) ) from ex
@nostrclient_ext.websocket("/api/v1/{id}") @nostrclient_api_router.websocket("/api/v1/{id}")
async def ws_relay(id: str, websocket: WebSocket) -> None: async def ws_relay(ws_id: str, websocket: WebSocket) -> None:
"""Relay multiplexer: one client (per endpoint) <-> multiple relays""" """Relay multiplexer: one client (per endpoint) <-> multiple relays"""
logger.info("New websocket connection at: '/api/v1/relay'") logger.info("New websocket connection at: '/api/v1/relay'")
try: try:
config = await get_config() config = await get_config()
assert config, "Failed to get config"
if not config.private_ws and not config.public_ws: if not config.private_ws and not config.public_ws:
raise ValueError("Websocket connections not accepted.") raise ValueError("Websocket connections not accepted.")
if id == "relay": if ws_id == "relay":
if not config.public_ws: if not config.public_ws:
raise ValueError("Public websocket connections not accepted.") raise ValueError("Public websocket connections not accepted.")
else: else:
if not config.private_ws: if not config.private_ws:
raise ValueError("Private websocket connections not accepted.") raise ValueError("Private websocket connections not accepted.")
if decrypt_internal_message(id) != "relay": if decrypt_internal_message(ws_id) != "relay":
raise ValueError("Invalid websocket endpoint.") raise ValueError("Invalid websocket endpoint.")
await websocket.accept() await websocket.accept()
router = NostrRouter(websocket) router = NostrRouter(websocket)
router.start() router.start()
@ -155,10 +161,10 @@ async def ws_relay(id: str, websocket: WebSocket) -> None:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot accept websocket connection", detail="Cannot accept websocket connection",
) ) from ex
@nostrclient_ext.get("/api/v1/config", dependencies=[Depends(check_admin)]) @nostrclient_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_get_config() -> Config: async def api_get_config() -> Config:
config = await get_config() config = await get_config()
if not config: if not config:
@ -167,10 +173,8 @@ async def api_get_config() -> Config:
return config return config
@nostrclient_ext.put("/api/v1/config", dependencies=[Depends(check_admin)]) @nostrclient_api_router.put("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_update_config( async def api_update_config(data: Config):
data: Config
):
config = await update_config(data) config = await update_config(data)
assert config assert config
return config.dict() return config.dict()