Compare commits

..

No commits in common. "e66f997853758fd6f7b48ac5962a9819844229fd" and "a8eb13936036c549547dcf6e688e2721eadc7b92" have entirely different histories.

18 changed files with 2681 additions and 2451 deletions

View file

@ -11,9 +11,14 @@ 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:
@ -25,5 +30,5 @@ jobs:
job-summary: true job-summary: true
emoji: false emoji: false
click-to-expand: true click-to-expand: true
custom-pytest: uv run pytest custom-pytest: poetry run pytest
report-title: 'test' report-title: 'test (${{ matrix.python-version }})'

View file

@ -5,27 +5,27 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier check: mypy pyright checkblack checkruff checkprettier
prettier: prettier:
uv run ./node_modules/.bin/prettier --write . poetry run ./node_modules/.bin/prettier --write .
pyright: pyright:
uv run ./node_modules/.bin/pyright poetry run ./node_modules/.bin/pyright
mypy: mypy:
uv run mypy . poetry run mypy .
black: black:
uv run black . poetry run black .
ruff: ruff:
uv run ruff check . --fix poetry run ruff check . --fix
checkruff: checkruff:
uv run ruff check . poetry run ruff check .
checkprettier: checkprettier:
uv run ./node_modules/.bin/prettier --check . poetry run ./node_modules/.bin/prettier --check .
checkblack: checkblack:
uv run black --check . poetry run black --check .
checkeditorconfig: checkeditorconfig:
editorconfig-checker editorconfig-checker
@ -33,14 +33,14 @@ checkeditorconfig:
test: test:
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
uv run pytest poetry 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 uv run pre-commit uninstall" @echo "Uninstall the hook with poetry run pre-commit uninstall"
uv run pre-commit install poetry run pre-commit install
pre-commit: pre-commit:
uv run pre-commit run --all-files poetry 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 .router import all_routers, nostr_client from .nostr_client 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", "dni"], "contributors": ["calle", "motorina0"],
"min_lnbits_version": "1.0.0" "min_lnbits_version": "0.12.0"
} }

77
crud.py
View file

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

View file

@ -1 +0,0 @@
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,10 +23,3 @@ 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,39 +1,38 @@
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, Field from pydantic import BaseModel
class RelayStatus(BaseModel): class RelayStatus(BaseModel):
num_sent_events: int | None = 0 num_sent_events: Optional[int] = 0
num_received_events: int | None = 0 num_received_events: Optional[int] = 0
error_counter: int | None = 0 error_counter: Optional[int] = 0
error_list: list | None = [] error_list: Optional[List] = []
notice_list: list | None = [] notice_list: Optional[List] = []
class Relay(BaseModel): class Relay(BaseModel):
id: str | None = None id: Optional[str] = None
url: str | None = None url: Optional[str] = None
active: bool | None = None connected: Optional[bool] = None
connected_string: Optional[str] = None
connected: bool | None = Field(default=None, no_database=True) status: Optional[RelayStatus] = None
connected_string: str | None = Field(default=None, no_database=True) active: Optional[bool] = None
status: RelayStatus | None = Field(default=None, no_database=True) ping: Optional[int] = None
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
class RelayDb(BaseModel): def from_row(cls, row: Row) -> "Relay":
id: str return cls(**dict(row))
url: str
active: bool | None = True
class TestMessage(BaseModel): class TestMessage(BaseModel):
sender_private_key: str | None sender_private_key: Optional[str]
reciever_public_key: str reciever_public_key: str
message: str message: str
@ -47,8 +46,3 @@ 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,12 +6,10 @@ 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:

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] = []

2531
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,45 +1,47 @@
[project] [tool.poetry]
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 = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = ["Alan Bits <alan@lnbits.com>"]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nostrclient" }
dependencies = [ "lnbits>1" ]
[tool.poetry] [tool.poetry.dependencies]
package-mode = false python = "^3.10 | ^3.9"
lnbits = "*"
[tool.uv] [tool.poetry.group.dev.dependencies]
dev-dependencies = [ black = "^24.3.0"
"black", pytest-asyncio = "^0.21.0"
"pytest-asyncio", pytest = "^7.3.2"
"pytest", mypy = "^1.5.1"
"mypy", pre-commit = "^3.2.2"
"pre-commit", ruff = "^0.3.2"
"ruff", types-cffi = "^1.16.0.20240331"
"pytest-md", pytest-md = "^0.2.0"
"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,35 +1,28 @@
import asyncio import asyncio
import json import json
from typing import ClassVar from typing import Dict, List
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 .nostr.client.client import NostrClient from . import nostr_client
# 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: ClassVar[dict[str, list[EventMessage]]] = {} received_subscription_events: dict[str, List[EventMessage]]
received_subscription_notices: ClassVar[list[NoticeMessage]] = [] received_subscription_notices: list[NoticeMessage]
received_subscription_eosenotices: ClassVar[dict[str, EndOfStoredEventsMessage]] = ( received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage]
{}
)
def __init__(self, websocket: WebSocket): def __init__(self, websocket: WebSocket):
self.connected: bool = True self.connected: bool = True
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):
@ -77,7 +70,6 @@ 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):
@ -111,14 +103,10 @@ 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 = json.dumps( event_to_forward = f"""["EVENT", "{s_original}", {event_json}]"""
["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.warning( logger.debug(e) # there are 2900 errors here
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,9 +3,10 @@ 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, nostr_client from .router import NostrRouter
async def init_relays(): async def init_relays():

View file

@ -71,7 +71,7 @@
<q-table <q-table
flat flat
dense dense
:rows="nostrrelayLinks" :data="nostrrelayLinks"
row-key="id" row-key="id"
:columns="relayTable.columns" :columns="relayTable.columns"
:pagination.sync="relayTable.pagination" :pagination.sync="relayTable.pagination"
@ -372,11 +372,12 @@
{% 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,
@ -390,7 +391,7 @@
if (obj.time_elapsed) { if (obj.time_elapsed) {
obj.date = 'Time elapsed' obj.date = 'Time elapsed'
} else { } else {
obj.date = Quasar.date.formatDate( obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000), new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss' 'HH:mm:ss'
) )
@ -398,7 +399,7 @@
return obj return obj
} }
window.app = Vue.createApp({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
@ -466,7 +467,8 @@
predefinedRelays: [ predefinedRelays: [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net', 'wss://nostr-pub.wellorder.net',
'wss://relay.nostrconnect.com', 'wss://nostr.zebedee.cloud',
'wss://nodestr.fmt.wiz.biz',
'wss://nostr.oxtr.dev', 'wss://nostr.oxtr.dev',
'wss://nostr.wine' 'wss://nostr.wine'
] ]
@ -622,7 +624,7 @@
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/relay/test', '/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
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

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,12 @@
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()
@ -14,5 +17,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.json()} "nostrclient/index.html", {"request": request, "user": user.dict()}
) )

View file

@ -17,7 +17,8 @@ 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 .router import NostrRouter, all_routers, nostr_client from .nostr_client import all_routers, nostr_client
from .router import NostrRouter
nostrclient_api_router = APIRouter() nostrclient_api_router = APIRouter()
@ -113,13 +114,13 @@ async def api_test_endpoint(data: TestMessage) -> TestMessageResponse:
) from ex ) from ex
@nostrclient_api_router.websocket("/api/v1/{ws_id}") @nostrclient_api_router.websocket("/api/v1/{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(owner_id="admin") config = await get_config()
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:
@ -131,7 +132,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, urlsafe=True) != "relay": if decrypt_internal_message(ws_id) != "relay":
raise ValueError("Invalid websocket endpoint.") raise ValueError("Invalid websocket endpoint.")
await websocket.accept() await websocket.accept()
@ -165,15 +166,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(owner_id="admin") config = await get_config()
if not config: if not config:
config = await create_config(owner_id="admin") config = await create_config()
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(owner_id="admin", config=data) config = await update_config(data)
assert config assert config
return config.dict() return config.dict()