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:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
release:
runs-on: ubuntu-latest
steps:
@ -34,12 +33,12 @@ jobs:
- 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"
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

24
.gitignore vendored
View file

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

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
from typing import List
from fastapi import APIRouter
from loguru import logger
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import create_permanent_unique_task
from .nostr.client.client import NostrClient
from .router import NostrRouter
db = Database("ext_nostrclient")
from .crud import db
from .nostr_client import all_routers, nostr_client
from .tasks import check_relays, init_relays, subscribe_events
from .views import nostrclient_generic_router
from .views_api import nostrclient_api_router
nostrclient_static_files = [
{
@ -21,23 +17,11 @@ nostrclient_static_files = [
]
nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"])
nostr_client: NostrClient = NostrClient()
# we keep this in
all_routers: list[NostrRouter] = []
nostrclient_ext.include_router(nostrclient_generic_router)
nostrclient_ext.include_router(nostrclient_api_router)
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():
for task in scheduled_tasks:
try:
@ -56,7 +40,20 @@ async def nostrclient_stop():
def nostrclient_start():
from lnbits.tasks import create_permanent_unique_task
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)
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
from typing import Optional
from lnbits.db import Database
from . import db
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")
return [Relay.from_row(r) for r in rows]
@ -37,27 +39,21 @@ async def create_config() -> Config:
INSERT INTO nostrclient.config (json_data)
VALUES (?)
""",
(json.dumps(config.dict())),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
(json.dumps(config.dict()),),
)
row = await db.fetchone("SELECT json_data FROM nostrclient.config", ())
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def update_config(config: Config) -> Optional[Config]:
await db.execute(
"""UPDATE nostrclient.config SET json_data = ?""",
(json.dumps(config.dict())),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
(json.dumps(config.dict()),),
)
row = await db.fetchone("SELECT json_data FROM nostrclient.config", ())
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def get_config() -> Optional[Config]:
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)) if row else None

View file

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

View file

@ -124,27 +124,29 @@ def decode(hrp, addr):
hrpgot, data, spec = bech32_decode(addr)
if hrpgot != hrp:
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:
return (None, None)
if data[0] > 16:
if data[0] > 16: # type: ignore
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)
if (
data[0] == 0
data[0] == 0 # type: ignore
and spec != Encoding.BECH32
or data[0] != 0
or data[0] != 0 # type: ignore
and spec != Encoding.BECH32M
):
return (None, None)
return (data[0], decoded)
return (data[0], decoded) # type: ignore
def encode(hrp, witver, witprog):
"""Encode a segwit address."""
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):
return None
return ret

View file

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

View file

@ -1,12 +1,13 @@
import base64
import secrets
from typing import Optional
import secp256k1
from cffi import FFI
from cryptography.hazmat.primitives import padding
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
@ -15,44 +16,51 @@ class PublicKey:
self.raw_bytes = raw_bytes
def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_bytes, 8, 5)
return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32)
converted_bits = convertbits(self.raw_bytes, 8, 5)
return bech32_encode("npub", converted_bits, Encoding.BECH32)
def hex(self) -> str:
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)
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
def from_npub(cls, npub: str):
"""Load a PublicKey from its bech32/npub form"""
hrp, data, spec = bech32.bech32_decode(npub)
raw_public_key = bech32.convertbits(data, 5, 8)[:-1]
hrp, data, spec = bech32_decode(npub)
raw_data = convertbits(data, 5, 8)
assert raw_data
raw_public_key = raw_data[:-1]
return cls(bytes(raw_public_key))
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:
self.raw_secret = raw_secret
else:
self.raw_secret = secrets.token_bytes(32)
sk = secp256k1.PrivateKey(self.raw_secret)
assert sk.pubkey
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
@classmethod
def from_nsec(cls, nsec: str):
"""Load a PrivateKey from its bech32/nsec form"""
hrp, data, spec = bech32.bech32_decode(nsec)
raw_secret = bech32.convertbits(data, 5, 8)[:-1]
hrp, data, spec = bech32_decode(nsec)
raw_data = convertbits(data, 5, 8)
assert raw_data
raw_secret = raw_data[:-1]
return cls(bytes(raw_secret))
def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_secret, 8, 5)
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32)
converted_bits = convertbits(self.raw_secret, 8, 5)
return bech32_encode("nsec", converted_bits, Encoding.BECH32)
def hex(self) -> str:
return self.raw_secret.hex()
@ -83,6 +91,8 @@ class PrivateKey:
)
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
assert dm.cleartext_content
assert dm.recipient_pubkey
dm.content = self.encrypt_message(
message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey
)
@ -105,14 +115,14 @@ class PrivateKey:
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)
sig = sk.schnorr_sign(hash, None, raw=True)
sig = sk.schnorr_sign(message_hash, None, raw=True)
return sig.hex()
def sign_event(self, event: Event) -> 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:
event.public_key = self.public_key.hex()
event.signature = self.sign_message_hash(bytes.fromhex(event.id))
@ -121,7 +131,9 @@ class PrivateKey:
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:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")

View file

@ -2,7 +2,6 @@ import asyncio
import json
import time
from queue import Queue
from typing import List
from loguru import logger
from websocket import WebSocketApp
@ -21,14 +20,14 @@ class Relay:
self.error_counter: int = 0
self.error_threshold: int = 100
self.error_list: List[str] = []
self.notice_list: List[str] = []
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
self.queue = Queue()
self.queue: Queue = Queue()
def connect(self):
self.ws = WebSocketApp(
@ -63,9 +62,10 @@ class Relay:
def publish(self, message: str):
self.queue.put(message)
def publish_subscriptions(self, subscriptions: List[Subscription] = []):
def publish_subscriptions(self, subscriptions: list[Subscription]):
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)
async def queue_worker(self):
@ -84,14 +84,14 @@ class Relay:
logger.warning(f"[Relay: {self.url}] Closing queue worker.")
return
def close_subscription(self, id: str) -> None:
def close_subscription(self, sub_id: str) -> None:
try:
self.publish(json.dumps(["CLOSE", id]))
self.publish(json.dumps(["CLOSE", sub_id]))
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
self.notice_list = [notice, *self.notice_list]
def _on_open(self, _):
logger.info(f"[Relay: {self.url}] Connected.")
@ -110,7 +110,7 @@ class Relay:
self.message_pool.add_message(message, self.url)
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.close()
@ -122,5 +122,5 @@ class Relay:
def _append_error_message(self, message):
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())

View file

@ -1,7 +1,7 @@
from typing import List
from typing import Optional
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.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 fastapi import WebSocket, WebSocketDisconnect
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from . import nostr_client
from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage
class NostrRouter:
received_subscription_events: dict[str, List[EventMessage]] = {}
received_subscription_notices: list[NoticeMessage] = []
received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage] = {}
received_subscription_events: dict[str, List[EventMessage]]
received_subscription_notices: list[NoticeMessage]
received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage]
def __init__(self, websocket: WebSocket):
self.connected: bool = True
@ -61,7 +60,7 @@ class NostrRouter:
try:
await self._handle_client_to_nostr(json_str)
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):
"""Sends responses from relays back to the client."""
@ -70,10 +69,9 @@ class NostrRouter:
await self._handle_subscriptions()
self._handle_notices()
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)
async def _handle_subscriptions(self):
for s in self.subscriptions:
if s in NostrRouter.received_subscription_events:
@ -155,6 +153,11 @@ class NostrRouter:
if subscription_id_rewritten:
self.original_subscription_ids.pop(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:
logger.info(f"Failed to unsubscribe from '{subscription_id}.'")

View file

@ -13,7 +13,7 @@ async def init_relays():
# get relays from db
relays = await get_relays()
# 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)
@ -25,38 +25,36 @@ async def check_relays():
await asyncio.sleep(20)
nostr_client.relay_manager.check_and_restart_relays()
except Exception as e:
logger.warning(f"Cannot restart relays: '{str(e)}'.")
logger.warning(f"Cannot restart relays: '{e!s}'.")
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)
def callback_events(eventMessage: EventMessage):
sub_id = eventMessage.subscription_id
def callback_events(event_message: EventMessage):
sub_id = event_message.subscription_id
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
# 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:
ids = [e.event_id for e in NostrRouter.received_subscription_events[sub_id]]
if event_message.event_id in ids:
return
NostrRouter.received_subscription_events[sub_id].append(eventMessage)
NostrRouter.received_subscription_events[sub_id].append(event_message)
def callback_notices(noticeMessage: NoticeMessage):
if noticeMessage not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(noticeMessage)
def callback_notices(notice_message: NoticeMessage):
if notice_message not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(notice_message)
def callback_eose_notices(eventMessage: EndOfStoredEventsMessage):
sub_id = eventMessage.subscription_id
def callback_eose_notices(event_message: EndOfStoredEventsMessage):
sub_id = event_message.subscription_id
if sub_id in NostrRouter.received_subscription_eosenotices:
return
NostrRouter.received_subscription_eosenotices[sub_id] = eventMessage
NostrRouter.received_subscription_eosenotices[sub_id] = event_message
def wrap_async_subscribe():
asyncio.run(

View file

@ -6,21 +6,44 @@
<q-form @submit="addRelay">
<div class="row">
<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 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-item v-for="relay in predefinedRelays" :key="relay" @click="addCustomRelay(relay)" clickable
v-close-popup>
<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-item
v-for="relay in predefinedRelays"
:key="relay"
@click="addCustomRelay(relay)"
clickable
v-close-popup
>
<q-item-section>
<q-item-label><span v-text="relay"></span></q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
<div class="col-6 col-md-2 q-pa-md">
<q-btn unelevated @click="config.showDialog = true" color="primary" icon="settings"
class="float-right"></q-btn>
<div class="col-6 col-md-2 q-pa-md">
<q-btn
unelevated
@click="config.showDialog = true"
color="primary"
icon="settings"
class="float-right"
></q-btn>
</div>
</div>
</q-form>
@ -32,18 +55,36 @@
<h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
</div>
<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>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table flat dense :data="nostrrelayLinks" row-key="id" :columns="relayTable.columns"
:pagination.sync="relayTable.pagination" :filter="filter">
<q-table
flat
dense
:data="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
:filter="filter"
>
<template v-slot:header="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-else>{{ col.label }}</div>
</q-th>
@ -52,7 +93,12 @@
<template v-slot:body="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.value">🟢</div>
<div v-else>🔴</div>
@ -61,17 +107,29 @@
<div>
⬆️ <span v-text="col.value.sentEvents"></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>
<span @click="showLogDataDialog(col.value.noticeList)" class="cursor-pointer float-right">
<span
@click="showLogDataDialog(col.value.noticeList)"
class="cursor-pointer float-right"
>
</span>
</div>
</div>
<div v-else-if="col.name == 'delete'">
<q-btn flat dense size="md" @click="showDeleteRelayDialog(props.row.url)" icon="cancel"
color="pink"></q-btn>
<q-btn
flat
dense
size="md"
@click="showDeleteRelayDialog(props.row.url)"
icon="cancel"
color="pink"
></q-btn>
</div>
<div v-else>
<div>{{ col.value }}</div>
@ -87,15 +145,32 @@
<div class="row">
<div class="col">
<div class="text-weight-bold">
<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>
<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:
<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>
</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-card-section>
<div class="row">
@ -103,8 +178,13 @@
<span>Sender Private Key:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.senderPrivateKey" dense filled
label="Private Key (optional)"></q-input>
<q-input
outlined
v-model="testData.senderPrivateKey"
dense
filled
label="Private Key (optional)"
></q-input>
</div>
</div>
<div class="row q-mt-sm q-mb-lg">
@ -113,7 +193,8 @@
<q-badge color="yellow" text-color="black">
<span>
No not use your real private key! Leave empty for a randomly
generated key.</span>
generated key.</span
>
</q-badge>
</div>
</div>
@ -122,7 +203,13 @@
<span>Sender Public Key:</span>
</div>
<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 class="row q-mt-md">
@ -130,8 +217,15 @@
<span>Test Message:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.message" dense filled rows="3" type="textarea"
label="Test Message *"></q-input>
<q-input
outlined
v-model="testData.message"
dense
filled
rows="3"
type="textarea"
label="Test Message *"
></q-input>
</div>
</div>
<div class="row q-mt-md">
@ -139,22 +233,35 @@
<span>Receiver Public Key:</span>
</div>
<div class="col-9">
<q-input outlined v-model="testData.recieverPublicKey" dense filled
label="Public Key (hex or npub) *"></q-input>
<q-input
outlined
v-model="testData.recieverPublicKey"
dense
filled
label="Public Key (hex or npub) *"
></q-input>
</div>
</div>
<div class="row q-mt-sm q-mb-lg">
<div class="col-3"></div>
<div class="col-9">
<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>
</div>
</div>
<div class="row">
<div class="col-12">
<q-btn :disabled="!testData.recieverPublicKey || !testData.message" @click="sendTestMessage" unelevated
color="primary" class="float-right">Send Message</q-btn>
<q-btn
:disabled="!testData.recieverPublicKey || !testData.message"
@click="sendTestMessage"
unelevated
color="primary"
class="float-right"
>Send Message</q-btn
>
</div>
</div>
</q-card-section>
@ -166,7 +273,14 @@
<span>Sent Data:</span>
</div>
<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 class="row q-mt-md">
@ -174,7 +288,14 @@
<span>Received Data:</span>
</div>
<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>
</q-card-section>
@ -193,8 +314,12 @@
</p>
<p>
<q-badge outline class="q-ml-sm text-subtitle2" color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`" />
<q-badge
outline
class="q-ml-sm text-subtitle2"
color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</p>
Only Admin users can manage this extension.
<q-card-section></q-card-section>
@ -204,7 +329,15 @@
<q-dialog v-model="logData.show" position="top">
<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">
<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-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<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 />
<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">
<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>
</q-form>
</q-card>
@ -272,7 +415,7 @@
},
config: {
showDialog: false,
data: {},
data: {}
},
testData: {
show: false,
@ -371,7 +514,7 @@
'POST',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{ url: this.relayToAdd }
{url: this.relayToAdd}
)
.then(function (response) {
console.log('response:', response)
@ -403,7 +546,7 @@
'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{ url: url }
{url: url}
)
.then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
@ -418,12 +561,11 @@
},
getConfig: async function () {
try {
const { data } = await LNbits.api
.request(
'GET',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey
)
const {data} = await LNbits.api.request(
'GET',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
@ -431,14 +573,13 @@
},
updateConfig: async function () {
try {
const { data } = await LNbits.api.request(
const {data} = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey,
this.config.data
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
@ -481,7 +622,7 @@
},
sendTestMessage: async function () {
try {
const { data } = await LNbits.api.request(
const {data} = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
@ -502,7 +643,7 @@
const subscription = JSON.stringify([
'REQ',
'test-dms',
{ kinds: [4], '#p': [event.pubkey] }
{kinds: [4], '#p': [event.pubkey]}
])
this.testData.wsConnection.send(subscription)
} 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 starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_admin
from . import nostr_renderer, nostrclient_ext
from lnbits.helpers import template_renderer
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)):
return nostr_renderer().TemplateResponse(
"nostrclient/index.html", {"request": request, "user": user.dict()}

View file

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