Compare commits

...

20 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
31 changed files with 3212 additions and 391 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: on:
push: push:
tags: tags:
- "v[0-9]+.[0-9]+.[0-9]+" - 'v[0-9]+.[0-9]+.[0-9]+'
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -34,12 +33,12 @@ jobs:
- name: Create pull request in extensions repo - name: Create pull request in extensions repo
env: env:
GH_TOKEN: ${{ secrets.EXT_GITHUB }} GH_TOKEN: ${{ secrets.EXT_GITHUB }}
repo_name: "${{ github.event.repository.name }}" repo_name: '${{ github.event.repository.name }}'
tag: "${{ github.ref_name }}" tag: '${{ github.ref_name }}'
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}" branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}'
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ 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 }}" 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" archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip'
run: | run: |
cd lnbits-extensions cd lnbits-extensions
git checkout -b $branch git checkout -b $branch

24
.gitignore vendored
View file

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

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> <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 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 import asyncio
from typing import List
from fastapi import APIRouter from fastapi import APIRouter
from loguru import logger
from lnbits.db import Database from .crud import db
from lnbits.helpers import template_renderer from .router import all_routers, nostr_client
from lnbits.tasks import catch_everything_and_restart from .tasks import check_relays, init_relays, subscribe_events
from .views import nostrclient_generic_router
from .nostr.client.client import NostrClient from .views_api import nostrclient_api_router
db = Database("ext_nostrclient")
nostrclient_static_files = [ nostrclient_static_files = [
{ {
@ -19,26 +17,43 @@ nostrclient_static_files = [
] ]
nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"]) nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"])
nostrclient_ext.include_router(nostrclient_generic_router)
scheduled_tasks: List[asyncio.Task] = [] nostrclient_ext.include_router(nostrclient_api_router)
scheduled_tasks: list[asyncio.Task] = []
nostr_client = NostrClient()
def nostr_renderer(): async def nostrclient_stop():
return template_renderer(["nostrclient/templates"]) 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 nostr_client.close()
from .views import * # noqa
from .views_api import * # noqa
def nostrclient_start(): def nostrclient_start():
loop = asyncio.get_event_loop() from lnbits.tasks import create_permanent_unique_task
task1 = loop.create_task(catch_everything_and_restart(init_relays))
scheduled_tasks.append(task1) task1 = create_permanent_unique_task("ext_nostrclient_init_relays", init_relays)
task2 = loop.create_task(catch_everything_and_restart(subscribe_events)) task2 = create_permanent_unique_task(
scheduled_tasks.append(task2) "ext_nostrclient_subscrive_events", subscribe_events
task3 = loop.create_task(catch_everything_and_restart(check_relays)) )
scheduled_tasks.append(task3) 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", "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", "tile": "/nostrclient/static/images/nostr-bitcoin.png",
"contributors": ["calle", "motorina0"], "contributors": ["calle", "motorina0", "dni"],
"min_lnbits_version": "0.12.0" "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"
} }

87
crud.py
View file

@ -1,63 +1,52 @@
from typing import List, Optional from lnbits.db import Database
import json from .models import Config, Relay, UserConfig
from . import db db = Database("ext_nostrclient")
from .models import Config, Relay
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:
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:
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( if user_config:
"SELECT json_data FROM nostrclient.config", () return user_config.extra
) return None
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def update_config(config: Config) -> Optional[Config]:
await db.execute(
"""UPDATE nostrclient.config SET json_data = ?""",
(json.dumps(config.dict())),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
)
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

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

@ -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,39 +1,39 @@
from sqlite3 import Row
from typing import List, Optional
from pydantic import BaseModel
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
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
@ -47,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

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

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

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

View file

@ -1,12 +1,11 @@
import base64 import base64
import secrets import secrets
import secp256k1 import coincurve
from cffi import FFI
from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 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 from .event import EncryptedDirectMessage, Event, EventKind
@ -15,55 +14,61 @@ class PublicKey:
self.raw_bytes = raw_bytes self.raw_bytes = raw_bytes
def bech32(self) -> str: def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_bytes, 8, 5) converted_bits = convertbits(self.raw_bytes, 8, 5)
return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32) return bech32_encode("npub", converted_bits, Encoding.BECH32)
def hex(self) -> str: def hex(self) -> str:
return self.raw_bytes.hex() return self.raw_bytes.hex()
def verify_signed_message_hash(self, hash: str, sig: str) -> bool: def verify_signed_message_hash(self, message_hash: str, sig: str) -> bool:
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True) pk = coincurve.PublicKeyXOnly(self.raw_bytes)
return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True) return pk.verify(bytes.fromhex(sig), bytes.fromhex(message_hash))
@classmethod @classmethod
def from_npub(cls, npub: str): def from_npub(cls, npub: str):
"""Load a PublicKey from its bech32/npub form""" """Load a PublicKey from its bech32/npub form"""
hrp, data, spec = bech32.bech32_decode(npub) _, data, _ = bech32_decode(npub)
raw_public_key = bech32.convertbits(data, 5, 8)[:-1] raw_data = convertbits(data, 5, 8)
assert raw_data
raw_public_key = raw_data[:-1]
return cls(bytes(raw_public_key)) return cls(bytes(raw_public_key))
class PrivateKey: 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: if raw_secret is not None:
self.raw_secret = raw_secret self.raw_secret = raw_secret
else: else:
self.raw_secret = secrets.token_bytes(32) self.raw_secret = secrets.token_bytes(32)
sk = secp256k1.PrivateKey(self.raw_secret) sk = coincurve.PrivateKey(self.raw_secret)
self.public_key = PublicKey(sk.pubkey.serialize()[1:]) assert sk.public_key
self.public_key = PublicKey(sk.public_key.format()[1:])
@classmethod @classmethod
def from_nsec(cls, nsec: str): def from_nsec(cls, nsec: str):
"""Load a PrivateKey from its bech32/nsec form""" """Load a PrivateKey from its bech32/nsec form"""
hrp, data, spec = bech32.bech32_decode(nsec) _, data, _ = bech32_decode(nsec)
raw_secret = bech32.convertbits(data, 5, 8)[:-1] raw_data = convertbits(data, 5, 8)
assert raw_data
raw_secret = raw_data[:-1]
return cls(bytes(raw_secret)) return cls(bytes(raw_secret))
def bech32(self) -> str: def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_secret, 8, 5) converted_bits = convertbits(self.raw_secret, 8, 5)
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32) return bech32_encode("nsec", converted_bits, Encoding.BECH32)
def hex(self) -> str: def hex(self) -> str:
return self.raw_secret.hex() return self.raw_secret.hex()
def tweak_add(self, scalar: bytes) -> bytes: def tweak_add(self, scalar: bytes) -> bytes:
sk = secp256k1.PrivateKey(self.raw_secret) sk = coincurve.PrivateKey(self.raw_secret)
return sk.tweak_add(scalar) return sk.add(scalar).to_der()
def compute_shared_secret(self, public_key_hex: str) -> bytes: def compute_shared_secret(self, public_key_hex: str) -> bytes:
pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True) pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex))
return pk.ecdh(self.raw_secret, hashfn=copy_x) sk = coincurve.PrivateKey(self.raw_secret)
return sk.ecdh(pk.format())
def encrypt_message(self, message: str, public_key_hex: str) -> str: def encrypt_message(self, message: str, public_key_hex: str) -> str:
padder = padding.PKCS7(128).padder() padder = padding.PKCS7(128).padder()
@ -83,6 +88,8 @@ class PrivateKey:
) )
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None: def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
assert dm.cleartext_content
assert dm.recipient_pubkey
dm.content = self.encrypt_message( dm.content = self.encrypt_message(
message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey
) )
@ -105,14 +112,14 @@ class PrivateKey:
return unpadded_data.decode() return unpadded_data.decode()
def sign_message_hash(self, hash: bytes) -> str: def sign_message_hash(self, message_hash: bytes) -> str:
sk = secp256k1.PrivateKey(self.raw_secret) sk = coincurve.PrivateKey(self.raw_secret)
sig = sk.schnorr_sign(hash, None, raw=True) sig = sk.sign_schnorr(message_hash)
return sig.hex() return sig.hex()
def sign_event(self, event: Event) -> None: def sign_event(self, event: Event) -> None:
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is 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: if event.public_key is None:
event.public_key = self.public_key.hex() event.public_key = self.public_key.hex()
event.signature = self.sign_message_hash(bytes.fromhex(event.id)) event.signature = self.sign_message_hash(bytes.fromhex(event.id))
@ -121,7 +128,7 @@ class PrivateKey:
return self.raw_secret == other.raw_secret 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: if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments") 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 break
return sk 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 json
import time import time
from queue import Queue from queue import Queue
from typing import List
from loguru import logger from loguru import logger
from websocket import WebSocketApp from websocket import WebSocketApp
@ -21,14 +20,14 @@ class Relay:
self.error_counter: int = 0 self.error_counter: int = 0
self.error_threshold: int = 100 self.error_threshold: int = 100
self.error_list: List[str] = [] self.error_list: list[str] = []
self.notice_list: List[str] = [] self.notice_list: list[str] = []
self.last_error_date: int = 0 self.last_error_date: int = 0
self.num_received_events: int = 0 self.num_received_events: int = 0
self.num_sent_events: int = 0 self.num_sent_events: int = 0
self.num_subscriptions: int = 0 self.num_subscriptions: int = 0
self.queue = Queue() self.queue: Queue = Queue()
def connect(self): def connect(self):
self.ws = WebSocketApp( self.ws = WebSocketApp(
@ -63,9 +62,10 @@ class Relay:
def publish(self, message: str): def publish(self, message: str):
self.queue.put(message) self.queue.put(message)
def publish_subscriptions(self, subscriptions: List[Subscription] = []): def publish_subscriptions(self, subscriptions: list[Subscription]):
for s in subscriptions: 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) self.publish(json_str)
async def queue_worker(self): async def queue_worker(self):
@ -84,14 +84,14 @@ class Relay:
logger.warning(f"[Relay: {self.url}] Closing queue worker.") logger.warning(f"[Relay: {self.url}] Closing queue worker.")
return return
def close_subscription(self, id: str) -> None: def close_subscription(self, sub_id: str) -> None:
try: try:
self.publish(json.dumps(["CLOSE", id])) self.publish(json.dumps(["CLOSE", sub_id]))
except Exception as e: except Exception as e:
logger.debug(f"[Relay: {self.url}] Failed to close subscription: {e}") logger.debug(f"[Relay: {self.url}] Failed to close subscription: {e}")
def add_notice(self, notice: str): def add_notice(self, notice: str):
self.notice_list = [notice] + self.notice_list self.notice_list = [notice, *self.notice_list]
def _on_open(self, _): def _on_open(self, _):
logger.info(f"[Relay: {self.url}] Connected.") logger.info(f"[Relay: {self.url}] Connected.")
@ -110,7 +110,7 @@ class Relay:
self.message_pool.add_message(message, self.url) self.message_pool.add_message(message, self.url)
def _on_error(self, _, error): 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._append_error_message(str(error))
self.close() self.close()
@ -122,5 +122,5 @@ class Relay:
def _append_error_message(self, message): def _append_error_message(self, message):
self.error_counter += 1 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()) self.last_error_date = int(time.time())

View file

@ -1,7 +1,7 @@
from typing import List from typing import Optional
class Subscription: 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.id = id
self.filters = filters 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 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 loguru import logger 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 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):
@ -61,7 +67,7 @@ class NostrRouter:
try: try:
await self._handle_client_to_nostr(json_str) await self._handle_client_to_nostr(json_str)
except Exception as e: 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): async def _nostr_to_client(self):
"""Sends responses from relays back to the client.""" """Sends responses from relays back to the client."""
@ -70,10 +76,10 @@ class NostrRouter:
await self._handle_subscriptions() await self._handle_subscriptions()
self._handle_notices() self._handle_notices()
except Exception as e: 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) await asyncio.sleep(0.1)
async def _handle_subscriptions(self): async def _handle_subscriptions(self):
for s in self.subscriptions: for s in self.subscriptions:
if s in NostrRouter.received_subscription_events: if s in NostrRouter.received_subscription_events:
@ -105,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):
@ -155,6 +165,11 @@ class NostrRouter:
if subscription_id_rewritten: if subscription_id_rewritten:
self.original_subscription_ids.pop(subscription_id_rewritten) self.original_subscription_ids.pop(subscription_id_rewritten)
nostr_client.relay_manager.close_subscription(subscription_id_rewritten) nostr_client.relay_manager.close_subscription(subscription_id_rewritten)
logger.info(f"Unsubscribe from '{subscription_id_rewritten}'. Original id: '{subscription_id}.'") logger.info(
f"""
Unsubscribe from '{subscription_id_rewritten}'.
Original id: '{subscription_id}.'
"""
)
else: else:
logger.info(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 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():
# get relays from db # get relays from db
relays = await get_relays() relays = await get_relays()
# set relays and connect to them # 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) nostr_client.reconnect(valid_relays)
@ -25,38 +24,36 @@ async def check_relays():
await asyncio.sleep(20) await asyncio.sleep(20)
nostr_client.relay_manager.check_and_restart_relays() nostr_client.relay_manager.check_and_restart_relays()
except Exception as e: except Exception as e:
logger.warning(f"Cannot restart relays: '{str(e)}'.") logger.warning(f"Cannot restart relays: '{e!s}'.")
async def subscribe_events(): 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) await asyncio.sleep(2)
def callback_events(eventMessage: EventMessage): def callback_events(event_message: EventMessage):
sub_id = eventMessage.subscription_id sub_id = event_message.subscription_id
if sub_id not in NostrRouter.received_subscription_events: 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 return
# do not add duplicate events (by event id) # do not add duplicate events (by event id)
ids = set( ids = [e.event_id for e in NostrRouter.received_subscription_events[sub_id]]
[e.event_id for e in NostrRouter.received_subscription_events[sub_id]] if event_message.event_id in ids:
)
if eventMessage.event_id in ids:
return return
NostrRouter.received_subscription_events[sub_id].append(eventMessage) NostrRouter.received_subscription_events[sub_id].append(event_message)
def callback_notices(noticeMessage: NoticeMessage): def callback_notices(notice_message: NoticeMessage):
if noticeMessage not in NostrRouter.received_subscription_notices: if notice_message not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(noticeMessage) NostrRouter.received_subscription_notices.append(notice_message)
def callback_eose_notices(eventMessage: EndOfStoredEventsMessage): def callback_eose_notices(event_message: EndOfStoredEventsMessage):
sub_id = eventMessage.subscription_id sub_id = event_message.subscription_id
if sub_id in NostrRouter.received_subscription_eosenotices: if sub_id in NostrRouter.received_subscription_eosenotices:
return return
NostrRouter.received_subscription_eosenotices[sub_id] = eventMessage NostrRouter.received_subscription_eosenotices[sub_id] = event_message
def wrap_async_subscribe(): def wrap_async_subscribe():
asyncio.run( asyncio.run(

View file

@ -6,12 +6,30 @@
<q-form @submit="addRelay"> <q-form @submit="addRelay">
<div class="row"> <div class="row">
<div class="col-12 col-md-7 q-pa-md"> <div class="col-12 col-md-7 q-pa-md">
<q-input outlined v-model="relayToAdd" dense filled label="Relay URL"></q-input> <q-input
outlined
v-model="relayToAdd"
dense
filled
label="Relay URL"
></q-input>
</div> </div>
<div class="col-6 col-md-3 q-pa-md"> <div class="col-6 col-md-3 q-pa-md">
<q-btn-dropdown unelevated split color="primary" class="float-left" type="submit" label="Add Relay Y"> <q-btn-dropdown
<q-item v-for="relay in predefinedRelays" :key="relay" @click="addCustomRelay(relay)" clickable unelevated
v-close-popup> split
color="primary"
class="float-left"
type="submit"
label="Add Relay"
>
<q-item
v-for="relay in predefinedRelays"
:key="relay"
@click="addCustomRelay(relay)"
clickable
v-close-popup
>
<q-item-section> <q-item-section>
<q-item-label><span v-text="relay"></span></q-item-label> <q-item-label><span v-text="relay"></span></q-item-label>
</q-item-section> </q-item-section>
@ -19,8 +37,13 @@
</q-btn-dropdown> </q-btn-dropdown>
</div> </div>
<div class="col-6 col-md-2 q-pa-md"> <div class="col-6 col-md-2 q-pa-md">
<q-btn unelevated @click="config.showDialog = true" color="primary" icon="settings" <q-btn
class="float-right"></q-btn> unelevated
@click="config.showDialog = true"
color="primary"
icon="settings"
class="float-right"
></q-btn>
</div> </div>
</div> </div>
</q-form> </q-form>
@ -32,18 +55,36 @@
<h5 class="text-subtitle1 q-my-none">Nostrclient</h5> <h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search"> <q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append> <template v-slot:append>
<q-icon name="search"></q-icon> <q-icon name="search"></q-icon>
</template> </template>
</q-input> </q-input>
</div> </div>
</div> </div>
<q-table flat dense :data="nostrrelayLinks" row-key="id" :columns="relayTable.columns" <q-table
:pagination.sync="relayTable.pagination" :filter="filter"> flat
dense
:rows="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
:filter="filter"
>
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width> <q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div> <div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div> <div v-else>{{ col.label }}</div>
</q-th> </q-th>
@ -52,7 +93,12 @@
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width> <q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'connected'"> <div v-if="col.name == 'connected'">
<div v-if="col.value">🟢</div> <div v-if="col.value">🟢</div>
<div v-else>🔴</div> <div v-else>🔴</div>
@ -61,17 +107,29 @@
<div> <div>
⬆️ <span v-text="col.value.sentEvents"></span> ⬇️ ⬆️ <span v-text="col.value.sentEvents"></span> ⬇️
<span v-text="col.value.receveidEvents"></span> <span v-text="col.value.receveidEvents"></span>
<span @click="showLogDataDialog(col.value.errorList)" class="cursor-pointer"> <span
@click="showLogDataDialog(col.value.errorList)"
class="cursor-pointer"
>
⚠️ <span v-text="col.value.errorCount"> </span> ⚠️ <span v-text="col.value.errorCount"> </span>
</span> </span>
<span @click="showLogDataDialog(col.value.noticeList)" class="cursor-pointer float-right"> <span
@click="showLogDataDialog(col.value.noticeList)"
class="cursor-pointer float-right"
>
</span> </span>
</div> </div>
</div> </div>
<div v-else-if="col.name == 'delete'"> <div v-else-if="col.name == 'delete'">
<q-btn flat dense size="md" @click="showDeleteRelayDialog(props.row.url)" icon="cancel" <q-btn
color="pink"></q-btn> flat
dense
size="md"
@click="showDeleteRelayDialog(props.row.url)"
icon="cancel"
color="pink"
></q-btn>
</div> </div>
<div v-else> <div v-else>
<div>{{ col.value }}</div> <div>{{ col.value }}</div>
@ -87,15 +145,32 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="text-weight-bold"> <div class="text-weight-bold">
<q-btn flat dense size="0.6rem" class="q-px-none q-mx-none" color="grey" icon="content_copy" <q-btn
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"><q-tooltip>Copy address</q-tooltip></q-btn> flat
dense
size="0.6rem"
class="q-px-none q-mx-none"
color="grey"
icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"
><q-tooltip>Copy address</q-tooltip></q-btn
>
Your endpoint: Your endpoint:
<q-badge outline class="q-ml-sm text-subtitle2" :label="`wss://${host}/nostrclient/api/v1/relay`" /> <q-badge
outline
class="q-ml-sm text-subtitle2"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</div> </div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
<q-expansion-item group="advanced" icon="settings" label="Test this endpoint" @click="toggleTestPanel"> <q-expansion-item
group="advanced"
icon="settings"
label="Test this endpoint"
@click="toggleTestPanel"
>
<q-separator></q-separator> <q-separator></q-separator>
<q-card-section> <q-card-section>
<div class="row"> <div class="row">
@ -103,8 +178,13 @@
<span>Sender Private Key:</span> <span>Sender Private Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.senderPrivateKey" dense filled <q-input
label="Private Key (optional)"></q-input> outlined
v-model="testData.senderPrivateKey"
dense
filled
label="Private Key (optional)"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-sm q-mb-lg"> <div class="row q-mt-sm q-mb-lg">
@ -113,7 +193,8 @@
<q-badge color="yellow" text-color="black"> <q-badge color="yellow" text-color="black">
<span> <span>
No not use your real private key! Leave empty for a randomly No not use your real private key! Leave empty for a randomly
generated key.</span> generated key.</span
>
</q-badge> </q-badge>
</div> </div>
</div> </div>
@ -122,7 +203,13 @@
<span>Sender Public Key:</span> <span>Sender Public Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.senderPublicKey" dense readonly filled></q-input> <q-input
outlined
v-model="testData.senderPublicKey"
dense
readonly
filled
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -130,8 +217,15 @@
<span>Test Message:</span> <span>Test Message:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.message" dense filled rows="3" type="textarea" <q-input
label="Test Message *"></q-input> outlined
v-model="testData.message"
dense
filled
rows="3"
type="textarea"
label="Test Message *"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -139,22 +233,35 @@
<span>Receiver Public Key:</span> <span>Receiver Public Key:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.recieverPublicKey" dense filled <q-input
label="Public Key (hex or npub) *"></q-input> outlined
v-model="testData.recieverPublicKey"
dense
filled
label="Public Key (hex or npub) *"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-sm q-mb-lg"> <div class="row q-mt-sm q-mb-lg">
<div class="col-3"></div> <div class="col-3"></div>
<div class="col-9"> <div class="col-9">
<q-badge color="yellow" text-color="black"> <q-badge color="yellow" text-color="black">
<span>This is the recipient of the message. Field required.</span> <span
>This is the recipient of the message. Field required.</span
>
</q-badge> </q-badge>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<q-btn :disabled="!testData.recieverPublicKey || !testData.message" @click="sendTestMessage" unelevated <q-btn
color="primary" class="float-right">Send Message</q-btn> :disabled="!testData.recieverPublicKey || !testData.message"
@click="sendTestMessage"
unelevated
color="primary"
class="float-right"
>Send Message</q-btn
>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -166,7 +273,14 @@
<span>Sent Data:</span> <span>Sent Data:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.sentData" dense filled rows="5" type="textarea"></q-input> <q-input
outlined
v-model="testData.sentData"
dense
filled
rows="5"
type="textarea"
></q-input>
</div> </div>
</div> </div>
<div class="row q-mt-md"> <div class="row q-mt-md">
@ -174,7 +288,14 @@
<span>Received Data:</span> <span>Received Data:</span>
</div> </div>
<div class="col-9"> <div class="col-9">
<q-input outlined v-model="testData.receivedData" dense filled rows="5" type="textarea"></q-input> <q-input
outlined
v-model="testData.receivedData"
dense
filled
rows="5"
type="textarea"
></q-input>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
@ -193,8 +314,12 @@
</p> </p>
<p> <p>
<q-badge outline class="q-ml-sm text-subtitle2" color="primary" <q-badge
:label="`wss://${host}/nostrclient/api/v1/relay`" /> outline
class="q-ml-sm text-subtitle2"
color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
</p> </p>
Only Admin users can manage this extension. Only Admin users can manage this extension.
<q-card-section></q-card-section> <q-card-section></q-card-section>
@ -204,7 +329,15 @@
<q-dialog v-model="logData.show" position="top"> <q-dialog v-model="logData.show" position="top">
<q-card class="q-pa-lg q-pt-xl"> <q-card class="q-pa-lg q-pt-xl">
<q-input filled dense v-model.trim="logData.data" type="textarea" rows="25" cols="200" label="Log Data"></q-input> <q-input
filled
dense
v-model.trim="logData.data"
type="textarea"
rows="25"
cols="200"
label="Log Data"
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
@ -215,12 +348,22 @@
<q-dialog v-model="config.showDialog" position="top"> <q-dialog v-model="config.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="updateConfig" class="q-gutter-md"> <q-form @submit="updateConfig" class="q-gutter-md">
<q-toggle label="Expose Private Websocket" color="secodary" v-model="config.data.private_ws"></q-toggle> <q-toggle
label="Expose Private Websocket"
color="secodary"
v-model="config.data.private_ws"
></q-toggle>
<br /> <br />
<q-toggle label="Expose Public Websocket" color="secodary" v-model="config.data.public_ws"></q-toggle> <q-toggle
label="Expose Public Websocket"
color="secodary"
v-model="config.data.public_ws"
></q-toggle>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Update</q-btn> <q-btn unelevated color="primary" type="submit">Update</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div> </div>
</q-form> </q-form>
</q-card> </q-card>
@ -229,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,
@ -248,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'
) )
@ -256,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 () {
@ -272,7 +414,7 @@
}, },
config: { config: {
showDialog: false, showDialog: false,
data: {}, data: {}
}, },
testData: { testData: {
show: false, show: false,
@ -324,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'
] ]
@ -335,11 +476,7 @@
getRelays: function () { getRelays: function () {
var self = this var self = this
LNbits.api LNbits.api
.request( .request('GET', '/nostrclient/api/v1/relays')
'GET',
'/nostrclient/api/v1/relays?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey
)
.then(function (response) { .then(function (response) {
if (response.data) { if (response.data) {
response.data.map(maplrelays) response.data.map(maplrelays)
@ -367,12 +504,9 @@
console.log('ADD RELAY ' + this.relayToAdd) console.log('ADD RELAY ' + this.relayToAdd)
let that = this let that = this
LNbits.api LNbits.api
.request( .request('POST', '/nostrclient/api/v1/relay', null, {
'POST', url: this.relayToAdd
'/nostrclient/api/v1/relay?usr=' + this.g.user.id, })
this.g.user.wallets[0].adminkey,
{ url: this.relayToAdd }
)
.then(function (response) { .then(function (response) {
console.log('response:', response) console.log('response:', response)
if (response.data) { if (response.data) {
@ -399,12 +533,7 @@
}, },
deleteRelay(url) { deleteRelay(url) {
LNbits.api LNbits.api
.request( .request('DELETE', '/nostrclient/api/v1/relay', null, {url: url})
'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{ url: url }
)
.then(response => { .then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url) const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
if (relayIndex !== -1) { if (relayIndex !== -1) {
@ -418,11 +547,9 @@
}, },
getConfig: async function () { getConfig: async function () {
try { try {
const { data } = await LNbits.api const {data} = await LNbits.api.request(
.request(
'GET', 'GET',
'/nostrclient/api/v1/config', '/nostrclient/api/v1/config'
this.g.user.wallets[0].adminkey
) )
this.config.data = data this.config.data = data
} catch (error) { } catch (error) {
@ -434,11 +561,10 @@
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
'/nostrclient/api/v1/config', '/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey, null,
this.config.data this.config.data
) )
this.config.data = data this.config.data = data
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }
@ -483,8 +609,8 @@
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, null,
{ {
sender_private_key: this.testData.senderPrivateKey, sender_private_key: this.testData.senderPrivateKey,
reciever_public_key: this.testData.recieverPublicKey, reciever_public_key: this.testData.recieverPublicKey,

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 import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse
from starlette.responses import HTMLResponse from lnbits.core.crud.users import get_user_from_account
from lnbits.core.models.users import Account
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 . import nostr_renderer, nostrclient_ext nostrclient_generic_router = APIRouter()
templates = Jinja2Templates(directory="templates")
@nostrclient_ext.get("/", response_class=HTMLResponse) def nostr_renderer():
async def index(request: Request, user: User = Depends(check_admin)): 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( 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 import asyncio
from http import HTTPStatus 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.decorators import check_admin
from lnbits.helpers import decrypt_internal_message, 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 (
from .crud import add_relay, create_config, delete_relay, get_config, get_relays, update_config add_relay,
create_config,
delete_relay,
get_config,
get_relays,
update_config,
)
from .helpers import normalize_public_key from .helpers import normalize_public_key
from .models import Config, Relay, TestMessage, TestMessageResponse from .models import Config, Relay, RelayStatus, TestMessage, TestMessageResponse
from .nostr.key import EncryptedDirectMessage, PrivateKey from .nostr.key import EncryptedDirectMessage, PrivateKey
from .router import NostrRouter from .router import NostrRouter, all_routers, nostr_client
# we keep this in nostrclient_api_router = APIRouter()
all_routers: list[NostrRouter] = []
@nostrclient_ext.get("/api/v1/relays", dependencies=[Depends(check_admin)]) @nostrclient_api_router.get("/api/v1/relays", dependencies=[Depends(check_admin)])
async def api_get_relays() -> List[Relay]: async def api_get_relays() -> list[Relay]:
relays = [] relays = []
for url, r in nostr_client.relay_manager.relays.items(): for url, r in nostr_client.relay_manager.relays.items():
relay_id = urlsafe_short_hash() relay_id = urlsafe_short_hash()
@ -30,13 +32,13 @@ async def api_get_relays() -> List[Relay]:
id=relay_id, id=relay_id,
url=url, url=url,
connected=r.connected, connected=r.connected,
status={ status=RelayStatus(
"num_sent_events": r.num_sent_events, num_sent_events=r.num_sent_events,
"num_received_events": r.num_received_events, num_received_events=r.num_received_events,
"error_counter": r.error_counter, error_counter=r.error_counter,
"error_list": r.error_list, error_list=r.error_list,
"notice_list": r.notice_list, notice_list=r.notice_list,
}, ),
ping=r.ping, ping=r.ping,
active=True, active=True,
) )
@ -44,10 +46,10 @@ async def api_get_relays() -> List[Relay]:
return relays return relays
@nostrclient_ext.post( @nostrclient_api_router.post(
"/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] "/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: if not relay.url:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Relay url not provided." 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() return await get_relays()
@nostrclient_ext.delete( @nostrclient_api_router.delete(
"/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] "/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
) )
async def api_delete_relay(relay: Relay) -> None: async def api_delete_relay(relay: Relay) -> None:
@ -78,7 +80,7 @@ async def api_delete_relay(relay: Relay) -> None:
await delete_relay(relay) await delete_relay(relay)
@nostrclient_ext.put( @nostrclient_api_router.put(
"/api/v1/relay/test", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] "/api/v1/relay/test", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
) )
async def api_test_endpoint(data: TestMessage) -> TestMessageResponse: async def api_test_endpoint(data: TestMessage) -> TestMessageResponse:
@ -102,58 +104,36 @@ async def api_test_endpoint(data: TestMessage) -> TestMessageResponse:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex), detail=str(ex),
) ) from ex
except Exception as ex: except Exception as ex:
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot generate test event", detail="Cannot generate test event",
) ) from ex
@nostrclient_ext.delete( @nostrclient_api_router.websocket("/api/v1/{ws_id}")
"/api/v1", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] async def ws_relay(ws_id: str, websocket: WebSocket) -> None:
)
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/{id}")
async def ws_relay(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"
if not config.private_ws and not config.public_ws: if not config.private_ws and not config.public_ws:
raise ValueError("Websocket connections not accepted.") raise ValueError("Websocket connections not accepted.")
if id == "relay": if ws_id == "relay":
if not config.public_ws: if not config.public_ws:
raise ValueError("Public websocket connections not accepted.") raise ValueError("Public websocket connections not accepted.")
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(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()
router = NostrRouter(websocket) router = NostrRouter(websocket)
router.start() router.start()
@ -180,21 +160,20 @@ async def ws_relay(id: str, websocket: WebSocket) -> None:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot accept websocket connection", detail="Cannot accept websocket connection",
) ) from ex
@nostrclient_ext.get("/api/v1/config", dependencies=[Depends(check_admin)]) @nostrclient_api_router.get("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_get_relays() -> Config: async def api_get_config() -> Config:
config = await get_config() config = await get_config(owner_id="admin")
if not config: if not config:
await create_config() config = await create_config(owner_id="admin")
assert config, "Failed to create config"
return config return config
@nostrclient_ext.put("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_update_config( @nostrclient_api_router.put("/api/v1/config", dependencies=[Depends(check_admin)])
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()