parent
d656d41b90
commit
a8eb139360
25 changed files with 3192 additions and 237 deletions
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal 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 }})'
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
|
|
@ -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
24
.gitignore
vendored
|
|
@ -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
12
.prettierrc
Normal 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
47
Makefile
Normal 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"
|
||||
45
__init__.py
45
__init__.py
|
|
@ -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
26
crud.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
44
nostr/key.py
44
nostr/key.py
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
5
nostr_client.py
Normal 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
59
package-lock.json
generated
Normal 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
15
package.json
Normal 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
2531
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
101
pyproject.toml
Normal file
101
pyproject.toml
Normal 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",
|
||||
]
|
||||
21
router.py
21
router.py
|
|
@ -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}.'")
|
||||
|
|
|
|||
32
tasks.py
32
tasks.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
0
tests/__init__.py
Normal file
11
tests/test_init.py
Normal file
11
tests/test_init.py
Normal 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)
|
||||
16
views.py
16
views.py
|
|
@ -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()}
|
||||
|
|
|
|||
72
views_api.py
72
views_api.py
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue