Compare commits

...

10 commits

Author SHA1 Message Date
PatMulligan
e66f997853
FIX: Ensure valid json (#39)
Some checks failed
CI / lint (push) Has been cancelled
/ release (push) Has been cancelled
CI / tests (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* Build EVENT message with json.dumps instead of string interpolation

Ensures outbound Nostr messages are valid JSON and safely escaped by
constructing the payload as Python objects and serializing with
json.dumps

* improve logs

* remove log causing check failure
2025-09-15 13:42:33 +03:00
blackcoffeexbt
02af516903
Update default relay list (#38)
nodestr.fmt.wiz.biz and the ZBD relays no longer exist. Added relay.nostrconnect.com as a new relay.
2025-09-10 11:11:39 +02:00
blackcoffeexbt
89f7c99f75
Merge pull request #37 from lnbits/feat/uv 2025-09-10 09:40:42 +01:00
dni ⚡
bd355a8a01
fixup! 2025-09-10 10:12:57 +02:00
dni ⚡
ed67ad3294
feat: use uv for dev 2025-09-10 10:08:24 +02:00
PatMulligan
c42735ca85
add urlsafe=True parameter (#34) 2025-07-01 12:10:38 +03:00
Tiago Vasconcelos
896f818da5
Merge pull request #33 from lnbits/add_description_md
Create description.md
2024-12-12 14:43:37 +00:00
Tiago Vasconcelos
47d52d7ec3
Create description.md 2024-12-11 14:14:24 +00:00
Vlad Stan
f5c048b22d fix: missing status 2024-11-06 11:47:05 +02:00
dni ⚡
db20915756
v1 in the middle (#32) 2024-10-31 13:15:31 +02:00
18 changed files with 2451 additions and 2681 deletions

View file

@ -11,14 +11,9 @@ jobs:
tests: tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [lint] needs: [lint]
strategy:
matrix:
python-version: ['3.9', '3.10']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev - uses: lnbits/lnbits/.github/actions/prepare@dev
with:
python-version: ${{ matrix.python-version }}
- name: Run pytest - name: Run pytest
uses: pavelzw/pytest-action@v2 uses: pavelzw/pytest-action@v2
env: env:
@ -30,5 +25,5 @@ jobs:
job-summary: true job-summary: true
emoji: false emoji: false
click-to-expand: true click-to-expand: true
custom-pytest: poetry run pytest custom-pytest: uv run pytest
report-title: 'test (${{ matrix.python-version }})' report-title: 'test'

View file

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

View file

@ -4,7 +4,7 @@ from fastapi import APIRouter
from loguru import logger from loguru import logger
from .crud import db from .crud import db
from .nostr_client import all_routers, nostr_client from .router import all_routers, nostr_client
from .tasks import check_relays, init_relays, subscribe_events from .tasks import check_relays, init_relays, subscribe_events
from .views import nostrclient_generic_router from .views import nostrclient_generic_router
from .views_api import nostrclient_api_router from .views_api import nostrclient_api_router
@ -53,7 +53,7 @@ def nostrclient_start():
__all__ = [ __all__ = [
"db", "db",
"nostrclient_ext", "nostrclient_ext",
"nostrclient_start",
"nostrclient_static_files", "nostrclient_static_files",
"nostrclient_stop", "nostrclient_stop",
"nostrclient_start",
] ]

View file

@ -2,6 +2,6 @@
"name": "Nostr Client", "name": "Nostr Client",
"short_description": "Nostr client for extensions", "short_description": "Nostr client for extensions",
"tile": "/nostrclient/static/images/nostr-bitcoin.png", "tile": "/nostrclient/static/images/nostr-bitcoin.png",
"contributors": ["calle", "motorina0"], "contributors": ["calle", "motorina0", "dni"],
"min_lnbits_version": "0.12.0" "min_lnbits_version": "1.0.0"
} }

75
crud.py
View file

@ -1,59 +1,52 @@
import json
from typing import Optional
from lnbits.db import Database from lnbits.db import Database
from .models import Config, Relay from .models import Config, Relay, UserConfig
db = Database("ext_nostrclient") 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 await db.fetchall(
return [Relay.from_row(r) for r in rows] "SELECT * FROM nostrclient.relays",
model=Relay,
)
async def add_relay(relay: Relay) -> None: async def add_relay(relay: Relay) -> Relay:
await db.execute( await db.insert("nostrclient.relays", relay)
""" return relay
INSERT INTO nostrclient.relays (
id,
url,
active
)
VALUES (?, ?, ?)
""",
(relay.id, relay.url, relay.active),
)
async def delete_relay(relay: Relay) -> None: async def delete_relay(relay: Relay) -> None:
await db.execute("DELETE FROM nostrclient.relays WHERE url = ?", (relay.url,)) if not relay.url:
return
await db.execute(
"DELETE FROM nostrclient.relays WHERE url = :url", {"url": relay.url}
)
######################CONFIG####################### ######################CONFIG#######################
async def create_config() -> Config: async def create_config(owner_id: str) -> Config:
config = Config() admin_config = UserConfig(owner_id=owner_id)
await db.execute( await db.insert("nostrclient.config", admin_config)
return admin_config.extra
async def update_config(owner_id: str, config: Config) -> Config:
user_config = UserConfig(owner_id=owner_id, extra=config)
await db.update("nostrclient.config", user_config, "WHERE owner_id = :owner_id")
return user_config.extra
async def get_config(owner_id: str) -> Config | None:
user_config: UserConfig = await db.fetchone(
""" """
INSERT INTO nostrclient.config (json_data) SELECT * FROM nostrclient.config
VALUES (?) WHERE owner_id = :owner_id
""", """,
(json.dumps(config.dict()),), {"owner_id": owner_id},
model=UserConfig,
) )
row = await db.fetchone("SELECT json_data FROM nostrclient.config", ()) if user_config:
return json.loads(row[0], object_hook=lambda d: Config(**d)) return user_config.extra
return None
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", ())
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", ())
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None

1
description.md Normal file
View file

@ -0,0 +1 @@
An always-on extension that can open multiple connections to nostr relays and act as a multiplexer for other clients: You open a single websocket to nostrclient which then sends the data to multiple relays. The responses from these relays are then sent back to the client.

View file

@ -23,3 +23,10 @@ async def m002_create_config_table(db):
json_data TEXT NOT NULL json_data TEXT NOT NULL
);""" );"""
) )
async def m003_update_config_table(db):
await db.execute("ALTER TABLE nostrclient.config RENAME COLUMN json_data TO extra")
await db.execute(
"ALTER TABLE nostrclient.config ADD COLUMN owner_id TEXT DEFAULT 'admin'"
)

View file

@ -1,38 +1,39 @@
from sqlite3 import Row
from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from pydantic import BaseModel from pydantic import BaseModel, Field
class RelayStatus(BaseModel): class RelayStatus(BaseModel):
num_sent_events: Optional[int] = 0 num_sent_events: int | None = 0
num_received_events: Optional[int] = 0 num_received_events: int | None = 0
error_counter: Optional[int] = 0 error_counter: int | None = 0
error_list: Optional[List] = [] error_list: list | None = []
notice_list: Optional[List] = [] notice_list: list | None = []
class Relay(BaseModel): class Relay(BaseModel):
id: Optional[str] = None id: str | None = None
url: Optional[str] = None url: str | None = None
connected: Optional[bool] = None active: bool | None = None
connected_string: Optional[str] = None
status: Optional[RelayStatus] = None connected: bool | None = Field(default=None, no_database=True)
active: Optional[bool] = None connected_string: str | None = Field(default=None, no_database=True)
ping: Optional[int] = None status: RelayStatus | None = Field(default=None, no_database=True)
ping: int | None = Field(default=None, no_database=True)
def _init__(self): def _init__(self):
if not self.id: if not self.id:
self.id = urlsafe_short_hash() self.id = urlsafe_short_hash()
@classmethod
def from_row(cls, row: Row) -> "Relay": class RelayDb(BaseModel):
return cls(**dict(row)) id: str
url: str
active: bool | None = True
class TestMessage(BaseModel): class TestMessage(BaseModel):
sender_private_key: Optional[str] sender_private_key: str | None
reciever_public_key: str reciever_public_key: str
message: str message: str
@ -46,3 +47,8 @@ class TestMessageResponse(BaseModel):
class Config(BaseModel): class Config(BaseModel):
private_ws: bool = True private_ws: bool = True
public_ws: bool = False public_ws: bool = False
class UserConfig(BaseModel):
owner_id: str
extra: Config = Config()

View file

@ -6,10 +6,12 @@ from ..relay_manager import RelayManager
class NostrClient: class NostrClient:
relay_manager = RelayManager() relay_manager: RelayManager
running: bool
def __init__(self): def __init__(self):
self.running = True self.running = True
self.relay_manager = RelayManager()
def connect(self, relays): def connect(self, relays):
for relay in relays: for relay in relays:

View file

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

2531
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,47 +1,45 @@
[tool.poetry] [project]
name = "lnbits-nostrclient" name = "lnbits-nostrclient"
version = "0.0.0" version = "0.0.0"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nostrclient" }
dependencies = [ "lnbits>1" ]
[tool.poetry.dependencies] [tool.poetry]
python = "^3.10 | ^3.9" package-mode = false
lnbits = "*"
[tool.poetry.group.dev.dependencies] [tool.uv]
black = "^24.3.0" dev-dependencies = [
pytest-asyncio = "^0.21.0" "black",
pytest = "^7.3.2" "pytest-asyncio",
mypy = "^1.5.1" "pytest",
pre-commit = "^3.2.2" "mypy",
ruff = "^0.3.2" "pre-commit",
types-cffi = "^1.16.0.20240331" "ruff",
pytest-md = "^0.2.0" "pytest-md",
"types-cffi",
[build-system] ]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy] [tool.mypy]
exclude = "(nostr/*)" exclude = "(nostr/*)"
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = [ module = [
"nostr.*", "nostr.*",
"lnbits.*",
"lnurl.*",
"loguru.*",
"fastapi.*",
"pydantic.*",
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
"secp256k1.*", "secp256k1.*",
"websocket.*",
] ]
follow_imports = "skip" follow_imports = "skip"
ignore_missing_imports = "True" ignore_missing_imports = "True"
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
log_cli = false log_cli = false
testpaths = [ testpaths = [

View file

@ -1,28 +1,35 @@
import asyncio import asyncio
import json import json
from typing import Dict, List from typing import ClassVar
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from loguru import logger from loguru import logger
from . import nostr_client from .nostr.client.client import NostrClient
# from . import nostr_client
from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage
nostr_client: NostrClient = NostrClient()
all_routers: list["NostrRouter"] = []
class NostrRouter: class NostrRouter:
received_subscription_events: dict[str, List[EventMessage]] received_subscription_events: ClassVar[dict[str, list[EventMessage]]] = {}
received_subscription_notices: list[NoticeMessage] received_subscription_notices: ClassVar[list[NoticeMessage]] = []
received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage] received_subscription_eosenotices: ClassVar[dict[str, EndOfStoredEventsMessage]] = (
{}
)
def __init__(self, websocket: WebSocket): def __init__(self, websocket: WebSocket):
self.connected: bool = True self.connected: bool = True
self.websocket: WebSocket = websocket self.websocket: WebSocket = websocket
self.tasks: List[asyncio.Task] = [] self.tasks: list[asyncio.Task] = []
self.original_subscription_ids: Dict[str, str] = {} self.original_subscription_ids: dict[str, str] = {}
@property @property
def subscriptions(self) -> List[str]: def subscriptions(self) -> list[str]:
return list(self.original_subscription_ids.keys()) return list(self.original_subscription_ids.keys())
def start(self): def start(self):
@ -70,6 +77,7 @@ class NostrRouter:
self._handle_notices() self._handle_notices()
except Exception as e: except Exception as e:
logger.debug(f"Failed to handle response for client: '{e!s}'.") logger.debug(f"Failed to handle response for client: '{e!s}'.")
await asyncio.sleep(1)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
async def _handle_subscriptions(self): async def _handle_subscriptions(self):
@ -103,10 +111,14 @@ class NostrRouter:
# this reconstructs the original response from the relay # this reconstructs the original response from the relay
# reconstruct original subscription id # reconstruct original subscription id
s_original = self.original_subscription_ids[s] s_original = self.original_subscription_ids[s]
event_to_forward = f"""["EVENT", "{s_original}", {event_json}]""" event_to_forward = json.dumps(
["EVENT", s_original, json.loads(event_json)]
)
await self.websocket.send_text(event_to_forward) await self.websocket.send_text(event_to_forward)
except Exception as e: except Exception as e:
logger.debug(e) # there are 2900 errors here logger.warning(
f"[NOSTRCLIENT] Error in _handle_received_subscription_events: {e}"
)
def _handle_notices(self): def _handle_notices(self):
while len(NostrRouter.received_subscription_notices): while len(NostrRouter.received_subscription_notices):

View file

@ -3,10 +3,9 @@ import threading
from loguru import logger from loguru import logger
from . import nostr_client
from .crud import get_relays from .crud import get_relays
from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage
from .router import NostrRouter from .router import NostrRouter, nostr_client
async def init_relays(): async def init_relays():

View file

@ -71,7 +71,7 @@
<q-table <q-table
flat flat
dense dense
:data="nostrrelayLinks" :rows="nostrrelayLinks"
row-key="id" row-key="id"
:columns="relayTable.columns" :columns="relayTable.columns"
:pagination.sync="relayTable.pagination" :pagination.sync="relayTable.pagination"
@ -372,12 +372,11 @@
{% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }} {% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script> <script>
Vue.component(VueQrcode.name, VueQrcode)
var maplrelays = obj => { var maplrelays = obj => {
obj._data = _.clone(obj) obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp) obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins' obj.time = obj.time + 'mins'
obj.status = obj.status || {}
obj.status = { obj.status = {
sentEvents: obj.status.num_sent_events, sentEvents: obj.status.num_sent_events,
receveidEvents: obj.status.num_received_events, receveidEvents: obj.status.num_received_events,
@ -391,7 +390,7 @@
if (obj.time_elapsed) { if (obj.time_elapsed) {
obj.date = 'Time elapsed' obj.date = 'Time elapsed'
} else { } else {
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.date.formatDate(
new Date((obj.theTime - 3600) * 1000), new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss' 'HH:mm:ss'
) )
@ -399,7 +398,7 @@
return obj return obj
} }
new Vue({ window.app = Vue.createApp({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
@ -467,8 +466,7 @@
predefinedRelays: [ predefinedRelays: [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net', 'wss://nostr-pub.wellorder.net',
'wss://nostr.zebedee.cloud', 'wss://relay.nostrconnect.com',
'wss://nodestr.fmt.wiz.biz',
'wss://nostr.oxtr.dev', 'wss://nostr.oxtr.dev',
'wss://nostr.wine' 'wss://nostr.wine'
] ]
@ -624,7 +622,7 @@
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id, '/nostrclient/api/v1/relay/test',
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ {
sender_private_key: this.testData.senderPrivateKey, sender_private_key: this.testData.senderPrivateKey,

2299
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -17,8 +17,7 @@ from .crud import (
from .helpers import normalize_public_key from .helpers import normalize_public_key
from .models import Config, Relay, RelayStatus, TestMessage, TestMessageResponse from .models import Config, Relay, RelayStatus, TestMessage, TestMessageResponse
from .nostr.key import EncryptedDirectMessage, PrivateKey from .nostr.key import EncryptedDirectMessage, PrivateKey
from .nostr_client import all_routers, nostr_client from .router import NostrRouter, all_routers, nostr_client
from .router import NostrRouter
nostrclient_api_router = APIRouter() nostrclient_api_router = APIRouter()
@ -114,13 +113,13 @@ async def api_test_endpoint(data: TestMessage) -> TestMessageResponse:
) from ex ) from ex
@nostrclient_api_router.websocket("/api/v1/{id}") @nostrclient_api_router.websocket("/api/v1/{ws_id}")
async def ws_relay(ws_id: str, websocket: WebSocket) -> None: async def ws_relay(ws_id: str, websocket: WebSocket) -> None:
"""Relay multiplexer: one client (per endpoint) <-> multiple relays""" """Relay multiplexer: one client (per endpoint) <-> multiple relays"""
logger.info("New websocket connection at: '/api/v1/relay'") logger.info("New websocket connection at: '/api/v1/relay'")
try: try:
config = await get_config() config = await get_config(owner_id="admin")
assert config, "Failed to get config" assert config, "Failed to get config"
if not config.private_ws and not config.public_ws: if not config.private_ws and not config.public_ws:
@ -132,7 +131,7 @@ async def ws_relay(ws_id: str, websocket: WebSocket) -> None:
else: else:
if not config.private_ws: if not config.private_ws:
raise ValueError("Private websocket connections not accepted.") raise ValueError("Private websocket connections not accepted.")
if decrypt_internal_message(ws_id) != "relay": if decrypt_internal_message(ws_id, urlsafe=True) != "relay":
raise ValueError("Invalid websocket endpoint.") raise ValueError("Invalid websocket endpoint.")
await websocket.accept() await websocket.accept()
@ -166,15 +165,15 @@ async def ws_relay(ws_id: str, websocket: WebSocket) -> None:
@nostrclient_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)]) @nostrclient_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_get_config() -> Config: async def api_get_config() -> Config:
config = await get_config() config = await get_config(owner_id="admin")
if not config: if not config:
config = await create_config() config = await create_config(owner_id="admin")
assert config, "Failed to create config" assert config, "Failed to create config"
return config return config
@nostrclient_api_router.put("/api/v1/config", dependencies=[Depends(check_admin)]) @nostrclient_api_router.put("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_update_config(data: Config): async def api_update_config(data: Config):
config = await update_config(data) config = await update_config(owner_id="admin", config=data)
assert config assert config
return config.dict() return config.dict()