Compare commits

...

21 commits

Author SHA1 Message Date
Vlad Stan
801ce44561 chore: clean&bump version
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-12-11 17:41:07 +02:00
Ben Weeks
507003ac20
Add extension metadata and screenshots for LNbits UI (#46)
* Add extension metadata and screenshots for LNbits UI

- Update description.md with comprehensive explanation of relay multiplexer
- Add screenshots array to config.json pointing to 1.jpeg and 2.jpeg
- Change short_description to "Nostr relay multiplexer" for clarity
- Create extensions.json with nostrclient-dev entries for testing
- Add CLAUDE.md for future Claude Code instances

Fixes #41

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Change extension name to 'Nostr Client Dev'

Rename extension from 'Nostr Client' to 'Nostr Client Dev' to distinguish from the official extension.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* FIX: Ensure valid json

Wrap extensions array in object with "featured" and "extensions" keys to match LNbits Manifest format. This fixes the "1 validation error for Manifest" issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update config.json format for LNbits UI display

- Change name to "Nostr Client Dev" to match extensions.json
- Replace "screenshots" with "images" array using proper format
- Add "description_md" URL for full description display

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Crop screenshot 1 to match height of screenshot 2

Crop bottom of 1.jpeg to make both screenshots the same height (1557px) for consistent display in LNbits UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove headings from description.md

Remove markdown headings for cleaner display in LNbits extension UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Change name back to 'Nostr Client' in config.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Delete CLAUDE.md

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-11 17:38:34 +02:00
Vlad Stan
5a078f2bfc
fix: fetch wallets for admin (#51)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-12-11 17:32:22 +02:00
Vlad Stan
d0f1089f08
chore: set version to 1.1.0 (#50)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-11-13 13:57:15 +02:00
Ben Weeks
571b034004
Improve README with simplified mermaid diagram and structured sections (#45)
* Improve README with mermaid diagram and structured sections

Replace static architecture diagram with maintainable mermaid flowchart showing the multiplexer architecture. Add structured sections for Overview, Features, Configuration, Testing, and Troubleshooting to better explain the extension's purpose and usage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Simplify mermaid diagram for better readability

Replace complex nested subgraphs with a simple, clear flowchart that matches the clarity of the original static diagram. Add note about subscription ID rewriting feature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-04 11:18:12 +01:00
dni ⚡
c871a42a85
refactor: get rid of libsecp (#49)
* refactor: get rid of libsecp

* fixup!
2025-11-04 10:30:00 +01:00
PatMulligan
e66f997853
FIX: Ensure valid json (#39)
* 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)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
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
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
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
dni ⚡
a8eb139360
feat: code quality (#31)
* feat: code quality
2024-08-30 13:07:33 +02:00
dni ⚡
d656d41b90
fix: properly start/stop tasks (#28)
* fix: properly start/stop tasks

https://github.com/lnbits/lnbits/issues/2411

* types

* nicer
2024-08-01 15:09:58 +02:00
Arc
942d997c70
Merge pull request #27 from arbadacarbaYK/main
Typo in button-desc
2024-02-17 13:57:20 +00:00
arbadacarba
9ba33d8ab8
Update index.html
Typo in button-desc
2024-02-15 09:46:06 +01:00
Vlad Stan
a119c3836a
Improve stability (#25)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
* chore: increase log level

* feat: secure relays endpoint

* feat: add config for the extension

* chore: update `min_lnbits_version`

* chore: improve logging

* fix: decrypt logic

* chore: improve logs
2024-01-22 13:46:22 +02:00
32 changed files with 3161 additions and 313 deletions

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

@ -0,0 +1,29 @@
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]
steps:
- uses: actions/checkout@v4
- uses: lnbits/lnbits/.github/actions/prepare@dev
- 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: uv run pytest
report-title: 'test'

View file

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

24
.gitignore vendored
View file

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

12
.prettierrc Normal file
View file

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

47
Makefile Normal file
View file

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

119
README.md
View file

@ -2,14 +2,123 @@
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
`nostrclient` is 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.
## Overview
![2023-03-08 18 11 07](https://user-images.githubusercontent.com/93376500/225265727-369f0f8a-196e-41df-a0d1-98b50a0228be.jpg)
`nostrclient` is an always-on Nostr relay multiplexer that simplifies connecting to multiple Nostr relays. Instead of your Nostr client managing connections to dozens of relays, you connect to a single WebSocket endpoint provided by `nostrclient`, which then fans out your requests to all configured relays and aggregates the responses back to you.
### Troubleshoot
### Why Use This?
The `Test Endpoint` functionality heps the user to check that the `nostrclient` web-socket endpoint works as expected.
- **Simplified Client Configuration** - Connect to one endpoint instead of managing multiple relay connections
- **Always-On Connectivity** - Your LNbits instance maintains persistent connections to relays
- **Resource Efficient** - Share relay connections across multiple clients
- **Subscription Management** - Automatic subscription ID rewriting prevents conflicts between clients
The LNbits user can DM itself (or a temp account) from `nostrclient` and verify that the messages are sent and received correctly.
## Architecture
```mermaid
flowchart LR
A[Client A] -->|WebSocket| N
B[Client B] -->|WebSocket| N
C[Client C] -->|WebSocket| N
N[nostrclient<br/>Router] -->|Fan Out| R1[Relay A]
N -->|Fan Out| R2[Relay B]
N -->|Fan Out| R3[Relay C]
N -->|Fan Out| R4[Relay D]
R1 -.->|Aggregate| N
R2 -.->|Aggregate| N
R3 -.->|Aggregate| N
R4 -.->|Aggregate| N
```
**Key Feature:** The router rewrites subscription IDs to prevent conflicts when multiple clients use the same IDs.
## Features
- **Multi-Relay Multiplexing** - Connect to multiple Nostr relays through a single WebSocket
- **Public & Private Endpoints** - Configurable public and private WebSocket access
- **Automatic Reconnection** - Failed relays are automatically retried with exponential backoff
- **Subscription Deduplication** - Events are deduplicated before being sent to clients
- **Health Monitoring** - Track relay connection status, latency, and error rates
- **Test Endpoint** - Send test messages to verify your setup is working
## How It Works
1. **Client Connection** - Your Nostr client connects to the nostrclient WebSocket endpoint
2. **Subscription Rewriting** - Each subscription ID is rewritten to prevent conflicts between multiple clients
3. **Fan-Out** - Subscription requests are sent to all configured relays
4. **Aggregation** - Events from all relays are collected and deduplicated
5. **Response** - Events are sent back to the client with the original subscription ID
## Configuration
### WebSocket Endpoints
- **Public Endpoint**: `/api/v1/relay` - Available to anyone (if enabled)
- **Private Endpoint**: `/api/v1/{encrypted_id}` - Requires valid encrypted endpoint ID
Configure endpoint access in the extension settings:
- `private_ws` - Enable/disable private WebSocket access
- `public_ws` - Enable/disable public WebSocket access
### Adding Relays
Use the nostrclient UI to add/remove Nostr relays. The extension will automatically:
- Connect to new relays
- Publish existing subscriptions to new relays
- Monitor relay health and reconnect as needed
## Testing
### Test Endpoint Functionality
The `Test Endpoint` feature helps verify that your nostrclient WebSocket endpoint works correctly.
**How to test:**
1. Navigate to the nostrclient extension in LNbits
2. Use the Test Endpoint feature
3. Send a DM to yourself (or a temporary account)
4. Verify that messages are sent and received correctly
https://user-images.githubusercontent.com/2951406/236780745-929c33c2-2502-49be-84a3-db02a7aabc0e.mp4
## Troubleshooting
### Connection Issues
- **Check relay status** - View relay health in the nostrclient UI
- **Verify endpoint configuration** - Ensure public_ws or private_ws is enabled
- **Check logs** - Review LNbits logs for connection errors
### Subscription Not Receiving Events
- **Verify relays are connected** - Check the relay status in the UI
- **Test with known event** - Use the Test Endpoint to verify connectivity
- **Check relay compatibility** - Some relays may not support all Nostr features
## Development
This extension uses `uv` for dependency management.
### Quick Start
```bash
# Format code
make format
# Run type checks and linting
make check
# Run tests
make test
```
For more development commands, see the [Makefile](./Makefile).
## License
MIT License - see [LICENSE](./LICENSE)

View file

@ -1,15 +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 catch_everything_and_restart
from .nostr.client.client import NostrClient
db = Database("ext_nostrclient")
from .crud import db
from .router 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 = [
{
@ -19,26 +17,43 @@ nostrclient_static_files = [
]
nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"])
scheduled_tasks: List[asyncio.Task] = []
nostr_client = NostrClient()
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"])
async def nostrclient_stop():
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
for router in all_routers:
try:
await router.stop()
all_routers.remove(router)
except Exception as e:
logger.error(e)
from .tasks import check_relays, init_relays, subscribe_events # noqa
from .views import * # noqa
from .views_api import * # noqa
nostr_client.close()
def nostrclient_start():
loop = asyncio.get_event_loop()
task1 = loop.create_task(catch_everything_and_restart(init_relays))
scheduled_tasks.append(task1)
task2 = loop.create_task(catch_everything_and_restart(subscribe_events))
scheduled_tasks.append(task2)
task3 = loop.create_task(catch_everything_and_restart(check_relays))
scheduled_tasks.append(task3)
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
)
task3 = create_permanent_unique_task("ext_nostrclient_check_relays", check_relays)
scheduled_tasks.extend([task1, task2, task3])
__all__ = [
"db",
"nostrclient_ext",
"nostrclient_start",
"nostrclient_static_files",
"nostrclient_stop",
]

View file

@ -1,7 +1,17 @@
{
"name": "Nostr Client",
"short_description": "Nostr client for extensions",
"short_description": "Nostr relay multiplexer",
"version": "1.1.0",
"tile": "/nostrclient/static/images/nostr-bitcoin.png",
"contributors": ["calle"],
"min_lnbits_version": "0.11.0"
"contributors": ["calle", "motorina0", "dni"],
"min_lnbits_version": "1.4.0",
"images": [
{
"uri": "https://raw.githubusercontent.com/lnbits/nostrclient/add-extension-metadata/static/images/1.jpeg"
},
{
"uri": "https://raw.githubusercontent.com/lnbits/nostrclient/add-extension-metadata/static/images/2.jpeg"
}
],
"description_md": "https://raw.githubusercontent.com/lnbits/nostrclient/add-extension-metadata/description.md"
}

65
crud.py
View file

@ -1,27 +1,52 @@
from typing import List
from lnbits.db import Database
from . import db
from .models import Relay
from .models import Config, Relay, UserConfig
db = Database("ext_nostrclient")
async def get_relays() -> List[Relay]:
rows = await db.fetchall("SELECT * FROM nostrclient.relays")
return [Relay.from_row(r) for r in rows]
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 get_relays() -> list[Relay]:
return await db.fetchall(
"SELECT * FROM nostrclient.relays",
model=Relay,
)
async def add_relay(relay: Relay) -> Relay:
await db.insert("nostrclient.relays", relay)
return relay
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#######################
async def create_config(owner_id: str) -> Config:
admin_config = UserConfig(owner_id=owner_id)
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(
"""
SELECT * FROM nostrclient.config
WHERE owner_id = :owner_id
""",
{"owner_id": owner_id},
model=UserConfig,
)
if user_config:
return user_config.extra
return None

8
description.md Normal file
View file

@ -0,0 +1,8 @@
An always-on relay multiplexer that simplifies connecting to multiple Nostr relays.
Instead of your Nostr client managing connections to dozens of relays, you connect to a single WebSocket endpoint provided by `nostrclient`, which then fans out your requests to all configured relays and aggregates the responses back to you.
- **Simplified Client Configuration** - Connect to one endpoint instead of managing multiple relay connections
- **Always-On Connectivity** - Your LNbits instance maintains persistent connections to relays
- **Resource Efficient** - Share relay connections across multiple clients
- **Automatic Subscription Management** - Subscription ID rewriting prevents conflicts between clients

View file

@ -11,3 +11,22 @@ async def m001_initial(db):
);
"""
)
async def m002_create_config_table(db):
"""
Allow the extension to persist and retrieve any number of config values.
"""
await db.execute(
"""CREATE TABLE nostrclient.config (
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,39 @@
from sqlite3 import Row
from typing import List, Optional
from pydantic import BaseModel
from lnbits.helpers import urlsafe_short_hash
from pydantic import BaseModel, Field
class RelayStatus(BaseModel):
num_sent_events: Optional[int] = 0
num_received_events: Optional[int] = 0
error_counter: Optional[int] = 0
error_list: Optional[List] = []
notice_list: Optional[List] = []
num_sent_events: int | None = 0
num_received_events: int | None = 0
error_counter: int | None = 0
error_list: list | None = []
notice_list: list | None = []
class Relay(BaseModel):
id: Optional[str] = None
url: Optional[str] = None
connected: Optional[bool] = None
connected_string: Optional[str] = None
status: Optional[RelayStatus] = None
active: Optional[bool] = None
ping: Optional[int] = None
id: str | None = None
url: str | None = None
active: bool | None = None
connected: bool | None = Field(default=None, no_database=True)
connected_string: str | None = Field(default=None, no_database=True)
status: RelayStatus | None = Field(default=None, no_database=True)
ping: int | None = Field(default=None, no_database=True)
def _init__(self):
if not self.id:
self.id = urlsafe_short_hash()
@classmethod
def from_row(cls, row: Row) -> "Relay":
return cls(**dict(row))
class RelayDb(BaseModel):
id: str
url: str
active: bool | None = True
class TestMessage(BaseModel):
sender_private_key: Optional[str]
sender_private_key: str | None
reciever_public_key: str
message: str
@ -42,3 +42,13 @@ class TestMessageResponse(BaseModel):
private_key: str
public_key: str
event_json: str
class Config(BaseModel):
private_ws: bool = True
public_ws: bool = False
class UserConfig(BaseModel):
owner_id: str
extra: Config = Config()

View file

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

View file

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

View file

@ -3,9 +3,9 @@ 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
import coincurve
from .message_type import ClientMessageType
@ -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,12 +73,10 @@ class Event:
self.tags.append(["e", event_id])
def verify(self) -> bool:
pub_key = PublicKey(
bytes.fromhex("02" + self.public_key), True
) # add 02 for schnorr (bip340)
return pub_key.schnorr_verify(
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
)
assert self.public_key
assert self.signature
pub_key = coincurve.PublicKeyXOnly(bytes.fromhex(self.public_key))
return pub_key.verify(bytes.fromhex(self.signature), bytes.fromhex(self.id))
def to_message(self) -> str:
return json.dumps(
@ -96,9 +97,9 @@ class Event:
@dataclass
class EncryptedDirectMessage(Event):
recipient_pubkey: str = None
cleartext_content: str = None
reference_event_id: str = None
recipient_pubkey: Optional[str] = None
cleartext_content: Optional[str] = None
reference_event_id: Optional[str] = None
def __post_init__(self):
if self.content is not None:

View file

@ -1,12 +1,11 @@
import base64
import secrets
import secp256k1
from cffi import FFI
import coincurve
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,55 +14,61 @@ 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:
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True)
def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool:
pk = coincurve.PublicKeyXOnly(self.raw_bytes)
return pk.verify(bytes.fromhex(sig), bytes.fromhex(message_hash))
@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]
_, data, _ = 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: bytes | None = 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)
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
sk = coincurve.PrivateKey(self.raw_secret)
assert sk.public_key
self.public_key = PublicKey(sk.public_key.format()[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]
_, data, _ = 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()
def tweak_add(self, scalar: bytes) -> bytes:
sk = secp256k1.PrivateKey(self.raw_secret)
return sk.tweak_add(scalar)
sk = coincurve.PrivateKey(self.raw_secret)
return sk.add(scalar).to_der()
def compute_shared_secret(self, public_key_hex: str) -> bytes:
pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True)
return pk.ecdh(self.raw_secret, hashfn=copy_x)
pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex))
sk = coincurve.PrivateKey(self.raw_secret)
return sk.ecdh(pk.format())
def encrypt_message(self, message: str, public_key_hex: str) -> str:
padder = padding.PKCS7(128).padder()
@ -83,6 +88,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 +112,14 @@ class PrivateKey:
return unpadded_data.decode()
def sign_message_hash(self, hash: bytes) -> str:
sk = secp256k1.PrivateKey(self.raw_secret)
sig = sk.schnorr_sign(hash, None, raw=True)
def sign_message_hash(self, message_hash: bytes) -> str:
sk = coincurve.PrivateKey(self.raw_secret)
sig = sk.sign_schnorr(message_hash)
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 +128,7 @@ 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: str | None = None, suffix: str | None = None) -> PrivateKey:
if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
@ -137,14 +144,3 @@ def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
break
return sk
ffi = FFI()
@ffi.callback(
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
)
def copy_x(output, x32, y32, data):
ffi.memmove(output, x32, 32)
return 1

View file

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

View file

@ -72,6 +72,7 @@ class RelayManager:
def close_subscription(self, id: str):
try:
logger.info(f"Closing subscription: '{id}'.")
with self._subscriptions_lock:
if id in self._cached_subscriptions:
self._cached_subscriptions.pop(id)

View file

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

59
package-lock.json generated Normal file
View file

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

15
package.json Normal file
View file

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

98
pyproject.toml Normal file
View file

@ -0,0 +1,98 @@
[project]
name = "lnbits-nostrclient"
version = "1.1.0"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/nostrclient" }
dependencies = [ "lnbits>1" ]
[tool.poetry]
package-mode = false
[tool.uv]
dev-dependencies = [
"black",
"pytest-asyncio",
"pytest",
"mypy",
"pre-commit",
"ruff",
"pytest-md",
"types-cffi",
]
[tool.mypy]
exclude = "(nostr/*)"
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = [
"nostr.*",
]
follow_imports = "skip"
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]
log_cli = false
testpaths = [
"tests"
]
[tool.black]
line-length = 88
[tool.ruff]
# Same as Black. + 10% rule of black
line-length = 88
exclude = [
"nostr",
]
[tool.ruff.lint]
# Enable:
# F - pyflakes
# E - pycodestyle errors
# W - pycodestyle warnings
# I - isort
# A - flake8-builtins
# C - mccabe
# N - naming
# UP - pyupgrade
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
ignore = ["C901"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
]
# Ignore unused imports in __init__.py files.
# [tool.ruff.lint.extend-per-file-ignores]
# "__init__.py" = ["F401", "F403"]
# [tool.ruff.lint.mccabe]
# max-complexity = 10
[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.Query",
]

View file

@ -1,29 +1,35 @@
import asyncio
import json
from typing import Dict, List
from typing import ClassVar
from fastapi import WebSocket, WebSocketDisconnect
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from .nostr.client.client import NostrClient
from . import nostr_client
# from . import nostr_client
from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage
nostr_client: NostrClient = NostrClient()
all_routers: list["NostrRouter"] = []
class NostrRouter:
received_subscription_events: dict[str, List[EventMessage]] = {}
received_subscription_notices: list[NoticeMessage] = []
received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage] = {}
received_subscription_events: ClassVar[dict[str, list[EventMessage]]] = {}
received_subscription_notices: ClassVar[list[NoticeMessage]] = []
received_subscription_eosenotices: ClassVar[dict[str, EndOfStoredEventsMessage]] = (
{}
)
def __init__(self, websocket: WebSocket):
self.connected: bool = True
self.websocket: WebSocket = websocket
self.tasks: List[asyncio.Task] = []
self.original_subscription_ids: Dict[str, str] = {}
self.tasks: list[asyncio.Task] = []
self.original_subscription_ids: dict[str, str] = {}
@property
def subscriptions(self) -> List[str]:
def subscriptions(self) -> list[str]:
return list(self.original_subscription_ids.keys())
def start(self):
@ -42,7 +48,7 @@ class NostrRouter:
pass
try:
await self.websocket.close()
await self.websocket.close(reason="Websocket connection closed")
except Exception as _:
pass
@ -61,7 +67,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 +76,10 @@ 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(1)
await asyncio.sleep(0.1)
async def _handle_subscriptions(self):
for s in self.subscriptions:
if s in NostrRouter.received_subscription_events:
@ -105,15 +111,19 @@ class NostrRouter:
# this reconstructs the original response from the relay
# reconstruct original subscription id
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)
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):
while len(NostrRouter.received_subscription_notices):
my_event = NostrRouter.received_subscription_notices.pop(0)
logger.info(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']")
logger.debug(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']")
# Note: we don't send it to the user because
# we don't know who should receive it
nostr_client.relay_manager.handle_notice(my_event)
@ -136,6 +146,7 @@ class NostrRouter:
def _handle_client_req(self, json_data):
subscription_id = json_data[1]
logger.info(f"New subscription: '{subscription_id}'")
subscription_id_rewritten = urlsafe_short_hash()
self.original_subscription_ids[subscription_id_rewritten] = subscription_id
filters = json_data[2:]
@ -154,5 +165,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}.'
"""
)
else:
logger.debug(f"Failed to unsubscribe from '{subscription_id}.'")
logger.info(f"Failed to unsubscribe from '{subscription_id}.'")

BIN
static/images/1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

BIN
static/images/2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View file

@ -3,17 +3,16 @@ import threading
from loguru import logger
from . import nostr_client
from .crud import get_relays
from .nostr.message_pool import EndOfStoredEventsMessage, EventMessage, NoticeMessage
from .router import NostrRouter
from .router import NostrRouter, nostr_client
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 +24,36 @@ async def check_relays():
await asyncio.sleep(20)
nostr_client.relay_manager.check_and_restart_relays()
except Exception as e:
logger.warning(f"Cannot restart relays: '{str(e)}'.")
logger.warning(f"Cannot restart relays: '{e!s}'.")
async def subscribe_events():
while not any([r.connected for r in nostr_client.relay_manager.relays.values()]):
while not [r.connected for r in nostr_client.relay_manager.relays.values()]:
await asyncio.sleep(2)
def callback_events(eventMessage: EventMessage):
sub_id = eventMessage.subscription_id
def callback_events(event_message: EventMessage):
sub_id = event_message.subscription_id
if sub_id not in NostrRouter.received_subscription_events:
NostrRouter.received_subscription_events[sub_id] = [eventMessage]
NostrRouter.received_subscription_events[sub_id] = [event_message]
return
# do not add duplicate events (by event id)
ids = set(
[e.event_id for e in NostrRouter.received_subscription_events[sub_id]]
)
if eventMessage.event_id in ids:
ids = [e.event_id for e in NostrRouter.received_subscription_events[sub_id]]
if event_message.event_id in ids:
return
NostrRouter.received_subscription_events[sub_id].append(eventMessage)
NostrRouter.received_subscription_events[sub_id].append(event_message)
def callback_notices(noticeMessage: NoticeMessage):
if noticeMessage not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(noticeMessage)
def callback_notices(notice_message: NoticeMessage):
if notice_message not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(notice_message)
def callback_eose_notices(eventMessage: EndOfStoredEventsMessage):
sub_id = eventMessage.subscription_id
def callback_eose_notices(event_message: EndOfStoredEventsMessage):
sub_id = event_message.subscription_id
if sub_id in NostrRouter.received_subscription_eosenotices:
return
NostrRouter.received_subscription_eosenotices[sub_id] = eventMessage
NostrRouter.received_subscription_eosenotices[sub_id] = event_message
def wrap_async_subscribe():
asyncio.run(

View file

@ -4,8 +4,8 @@
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-form @submit="addRelay">
<div class="row q-pa-md">
<div class="col-9">
<div class="row">
<div class="col-12 col-md-7 q-pa-md">
<q-input
outlined
v-model="relayToAdd"
@ -14,14 +14,14 @@
label="Relay URL"
></q-input>
</div>
<div class="col-3">
<div class="col-6 col-md-3 q-pa-md">
<q-btn-dropdown
unelevated
split
color="primary"
class="float-right"
class="float-left"
type="submit"
label="Add Relay X"
label="Add Relay"
>
<q-item
v-for="relay in predefinedRelays"
@ -36,6 +36,15 @@
</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>
</div>
</q-form>
</q-card>
@ -62,7 +71,7 @@
<q-table
flat
dense
:data="nostrrelayLinks"
:rows="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
@ -335,16 +344,39 @@
</div>
</q-card>
</q-dialog>
<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>
<br />
<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
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
var maplrelays = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
obj.status = obj.status || {}
obj.status = {
sentEvents: obj.status.num_sent_events,
receveidEvents: obj.status.num_received_events,
@ -358,7 +390,7 @@
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
obj.date = Quasar.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
@ -366,7 +398,7 @@
return obj
}
new Vue({
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
data: function () {
@ -380,6 +412,10 @@
show: false,
data: null
},
config: {
showDialog: false,
data: {}
},
testData: {
show: false,
wsConnection: null,
@ -430,8 +466,7 @@
predefinedRelays: [
'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://nodestr.fmt.wiz.biz',
'wss://relay.nostrconnect.com',
'wss://nostr.oxtr.dev',
'wss://nostr.wine'
]
@ -441,11 +476,7 @@
getRelays: function () {
var self = this
LNbits.api
.request(
'GET',
'/nostrclient/api/v1/relays?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey
)
.request('GET', '/nostrclient/api/v1/relays')
.then(function (response) {
if (response.data) {
response.data.map(maplrelays)
@ -473,12 +504,9 @@
console.log('ADD RELAY ' + this.relayToAdd)
let that = this
LNbits.api
.request(
'POST',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: this.relayToAdd}
)
.request('POST', '/nostrclient/api/v1/relay', null, {
url: this.relayToAdd
})
.then(function (response) {
console.log('response:', response)
if (response.data) {
@ -505,12 +533,7 @@
},
deleteRelay(url) {
LNbits.api
.request(
'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: url}
)
.request('DELETE', '/nostrclient/api/v1/relay', null, {url: url})
.then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
if (relayIndex !== -1) {
@ -522,6 +545,31 @@
LNbits.utils.notifyApiError(error)
})
},
getConfig: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/nostrclient/api/v1/config'
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateConfig: async function () {
try {
const {data} = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/config',
null,
this.config.data
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.config.showDialog = false
},
toggleTestPanel: async function () {
if (this.testData.show) {
await this.hideTestPannel()
@ -561,8 +609,8 @@
try {
const {data} = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
'/nostrclient/api/v1/relay/test',
null,
{
sender_private_key: this.testData.senderPrivateKey,
reciever_public_key: this.testData.recieverPublicKey,
@ -642,9 +690,9 @@
},
sleep: ms => new Promise(r => setTimeout(r, ms))
},
created: function () {
var self = this
created: async function () {
this.getRelays()
await this.getConfig()
setInterval(this.getRelays, 5000)
}
})

0
tests/__init__.py Normal file
View file

11
tests/test_init.py Normal file
View file

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

2305
uv.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,22 @@
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from lnbits.core.crud.users import get_user_from_account
from lnbits.core.models.users import Account
from lnbits.decorators import check_admin
from lnbits.helpers import template_renderer
from . import nostr_renderer, nostrclient_ext
templates = Jinja2Templates(directory="templates")
nostrclient_generic_router = APIRouter()
@nostrclient_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_admin)):
def nostr_renderer():
return template_renderer(["nostrclient/templates"])
@nostrclient_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, account: Account = Depends(check_admin)):
user = await get_user_from_account(account)
if not user:
return HTMLResponse("No user found", status_code=404)
return nostr_renderer().TemplateResponse(
"nostrclient/index.html", {"request": request, "user": user.dict()}
"nostrclient/index.html", {"request": request, "user": user.json()}
)

View file

@ -1,27 +1,29 @@
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 urlsafe_short_hash
from lnbits.helpers import decrypt_internal_message, urlsafe_short_hash
from loguru import logger
from . import nostr_client, nostrclient_ext, scheduled_tasks
from .crud import add_relay, delete_relay, get_relays
from .crud import (
add_relay,
create_config,
delete_relay,
get_config,
get_relays,
update_config,
)
from .helpers import normalize_public_key
from .models import Relay, TestMessage, TestMessageResponse
from .models import Config, Relay, RelayStatus, TestMessage, TestMessageResponse
from .nostr.key import EncryptedDirectMessage, PrivateKey
from .router import NostrRouter
from .router import NostrRouter, all_routers, nostr_client
# we keep this in
all_routers: list[NostrRouter] = []
nostrclient_api_router = APIRouter()
@nostrclient_ext.get("/api/v1/relays")
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()
@ -30,13 +32,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,
)
@ -44,10 +46,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."
@ -65,7 +67,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:
@ -78,7 +80,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:
@ -102,40 +104,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.delete(
"/api/v1", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
async def api_stop():
for router in all_routers:
try:
await router.stop()
all_routers.remove(router)
except Exception as e:
logger.error(e)
nostr_client.close()
for scheduled_task in scheduled_tasks:
try:
scheduled_task.cancel()
except Exception as ex:
logger.warning(ex)
return {"success": True}
@nostrclient_ext.websocket("/api/v1/relay")
async def ws_relay(websocket: WebSocket) -> None:
@nostrclient_api_router.websocket("/api/v1/{ws_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(owner_id="admin")
assert config, "Failed to get config"
if not config.private_ws and not config.public_ws:
raise ValueError("Websocket connections not accepted.")
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(ws_id, urlsafe=True) != "relay":
raise ValueError("Invalid websocket endpoint.")
await websocket.accept()
router = NostrRouter(websocket)
router.start()
@ -146,6 +144,36 @@ async def ws_relay(websocket: WebSocket) -> None:
while router.connected:
await asyncio.sleep(10)
try:
await router.stop()
all_routers.remove(router)
except Exception as e:
logger.debug(e)
all_routers.remove(router)
logger.info("Closed websocket connection at: '/api/v1/relay'")
except ValueError as ex:
logger.warning(ex)
await websocket.close(reason=str(ex))
except Exception as ex:
logger.warning(ex)
await websocket.close(reason="Websocket connection unexpected closed")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot accept websocket connection",
) from ex
@nostrclient_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_get_config() -> Config:
config = await get_config(owner_id="admin")
if not config:
config = await create_config(owner_id="admin")
assert config, "Failed to create config"
return config
@nostrclient_api_router.put("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_update_config(data: Config):
config = await update_config(owner_id="admin", config=data)
assert config
return config.dict()