Compare commits

..

No commits in common. "main" and "v0.3.7" have entirely different histories.

32 changed files with 313 additions and 3161 deletions

View file

@ -1,29 +0,0 @@
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,9 +1,10 @@
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
runs-on: ubuntu-latest
steps:
@ -33,12 +34,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,4 +1,24 @@
.DS_Store
._*
__pycache__
node_modules
*.py[cod]
*$py.class
.mypy_cache
.venv
.vscode
*-lock.json
*.egg
*.egg-info
.coverage
.pytest_cache
.webassets-cache
htmlcov
test-reports
tests/data/*.sqlite3
*.swo
*.swp
*.pyo
*.pyc
*.env

View file

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

View file

@ -1,47 +0,0 @@
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,123 +2,14 @@
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Overview
`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.
`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.
![2023-03-08 18 11 07](https://user-images.githubusercontent.com/93376500/225265727-369f0f8a-196e-41df-a0d1-98b50a0228be.jpg)
### Why Use This?
### Troubleshoot
- **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 `Test Endpoint` functionality heps the user to check that the `nostrclient` web-socket endpoint works as expected.
## 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
The LNbits user can DM itself (or a temp account) from `nostrclient` and verify that the 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,13 +1,15 @@
import asyncio
from typing import List
from fastapi import APIRouter
from loguru import logger
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
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")
nostrclient_static_files = [
{
@ -17,43 +19,26 @@ nostrclient_static_files = [
]
nostrclient_ext: APIRouter = APIRouter(prefix="/nostrclient", tags=["nostrclient"])
nostrclient_ext.include_router(nostrclient_generic_router)
nostrclient_ext.include_router(nostrclient_api_router)
scheduled_tasks: list[asyncio.Task] = []
scheduled_tasks: List[asyncio.Task] = []
nostr_client = NostrClient()
async def nostrclient_stop():
for task in scheduled_tasks:
try:
task.cancel()
except Exception as ex:
logger.warning(ex)
def nostr_renderer():
return template_renderer(["nostrclient/templates"])
for router in all_routers:
try:
await router.stop()
all_routers.remove(router)
except Exception as e:
logger.error(e)
nostr_client.close()
from .tasks import check_relays, init_relays, subscribe_events # noqa
from .views import * # noqa
from .views_api import * # noqa
def nostrclient_start():
from lnbits.tasks import create_permanent_unique_task
task1 = create_permanent_unique_task("ext_nostrclient_init_relays", init_relays)
task2 = create_permanent_unique_task(
"ext_nostrclient_subscrive_events", subscribe_events
)
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",
]
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)

View file

@ -1,17 +1,7 @@
{
"name": "Nostr Client",
"short_description": "Nostr relay multiplexer",
"version": "1.1.0",
"short_description": "Nostr client for extensions",
"tile": "/nostrclient/static/images/nostr-bitcoin.png",
"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"
"contributors": ["calle"],
"min_lnbits_version": "0.11.0"
}

65
crud.py
View file

@ -1,52 +1,27 @@
from lnbits.db import Database
from typing import List
from .models import Config, Relay, UserConfig
db = Database("ext_nostrclient")
from . import db
from .models import Relay
async def get_relays() -> list[Relay]:
return await db.fetchall(
"SELECT * FROM nostrclient.relays",
model=Relay,
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 add_relay(relay: Relay) -> Relay:
await db.insert("nostrclient.relays", relay)
return relay
async def delete_relay(relay: Relay) -> None:
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
await db.execute("DELETE FROM nostrclient.relays WHERE url = ?", (relay.url,))

View file

@ -1,8 +0,0 @@
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,22 +11,3 @@ 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: int | None = 0
num_received_events: int | None = 0
error_counter: int | None = 0
error_list: list | None = []
notice_list: list | None = []
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] = []
class Relay(BaseModel):
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)
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
def _init__(self):
if not self.id:
self.id = urlsafe_short_hash()
class RelayDb(BaseModel):
id: str
url: str
active: bool | None = True
@classmethod
def from_row(cls, row: Row) -> "Relay":
return cls(**dict(row))
class TestMessage(BaseModel):
sender_private_key: str | None
sender_private_key: Optional[str]
reciever_public_key: str
message: str
@ -42,13 +42,3 @@ 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,29 +124,27 @@ def decode(hrp, addr):
hrpgot, data, spec = bech32_decode(addr)
if hrpgot != hrp:
return (None, None)
decoded = convertbits(data[1:], 5, 8, False) # type: ignore
decoded = convertbits(data[1:], 5, 8, False)
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
return (None, None)
if data[0] > 16: # type: ignore
if data[0] > 16:
return (None, None)
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: # type: ignore
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
return (None, None)
if (
data[0] == 0 # type: ignore
data[0] == 0
and spec != Encoding.BECH32
or data[0] != 0 # type: ignore
or data[0] != 0
and spec != Encoding.BECH32M
):
return (None, None)
return (data[0], decoded) # type: ignore
return (data[0], decoded)
def encode(hrp, witver, witprog):
"""Encode a segwit address."""
spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
wit_prog = convertbits(witprog, 8, 5)
assert wit_prog
ret = bech32_encode(hrp, [witver, *wit_prog], spec)
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec)
if decode(hrp, ret) == (None, None):
return None
return ret

View file

@ -6,12 +6,10 @@ from ..relay_manager import RelayManager
class NostrClient:
relay_manager: RelayManager
running: bool
relay_manager = RelayManager()
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 Optional
from typing import List
import coincurve
from secp256k1 import PublicKey
from .message_type import ClientMessageType
@ -21,14 +21,14 @@ class EventKind(IntEnum):
@dataclass
class Event:
content: Optional[str] = None
public_key: Optional[str] = None
created_at: Optional[int] = None
content: str = None
public_key: str = None
created_at: 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: Optional[str] = None
signature: 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,9 +57,6 @@ 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
)
@ -73,10 +70,12 @@ class Event:
self.tags.append(["e", event_id])
def verify(self) -> bool:
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))
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
)
def to_message(self) -> str:
return json.dumps(
@ -97,9 +96,9 @@ class Event:
@dataclass
class EncryptedDirectMessage(Event):
recipient_pubkey: Optional[str] = None
cleartext_content: Optional[str] = None
reference_event_id: Optional[str] = None
recipient_pubkey: str = None
cleartext_content: str = None
reference_event_id: str = None
def __post_init__(self):
if self.content is not None:

View file

@ -1,11 +1,12 @@
import base64
import secrets
import coincurve
import secp256k1
from cffi import FFI
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from .bech32 import Encoding, bech32_decode, bech32_encode, convertbits
from . import bech32
from .event import EncryptedDirectMessage, Event, EventKind
@ -14,61 +15,55 @@ class PublicKey:
self.raw_bytes = raw_bytes
def bech32(self) -> str:
converted_bits = convertbits(self.raw_bytes, 8, 5)
return bech32_encode("npub", converted_bits, Encoding.BECH32)
converted_bits = bech32.convertbits(self.raw_bytes, 8, 5)
return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32)
def hex(self) -> str:
return self.raw_bytes.hex()
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))
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)
@classmethod
def from_npub(cls, npub: str):
"""Load a PublicKey from its bech32/npub form"""
_, data, _ = bech32_decode(npub)
raw_data = convertbits(data, 5, 8)
assert raw_data
raw_public_key = raw_data[:-1]
hrp, data, spec = bech32.bech32_decode(npub)
raw_public_key = bech32.convertbits(data, 5, 8)[:-1]
return cls(bytes(raw_public_key))
class PrivateKey:
def __init__(self, raw_secret: bytes | None = None) -> None:
def __init__(self, raw_secret: bytes = None) -> None:
if raw_secret is not None:
self.raw_secret = raw_secret
else:
self.raw_secret = secrets.token_bytes(32)
sk = coincurve.PrivateKey(self.raw_secret)
assert sk.public_key
self.public_key = PublicKey(sk.public_key.format()[1:])
sk = secp256k1.PrivateKey(self.raw_secret)
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
@classmethod
def from_nsec(cls, nsec: str):
"""Load a PrivateKey from its bech32/nsec form"""
_, data, _ = bech32_decode(nsec)
raw_data = convertbits(data, 5, 8)
assert raw_data
raw_secret = raw_data[:-1]
hrp, data, spec = bech32.bech32_decode(nsec)
raw_secret = bech32.convertbits(data, 5, 8)[:-1]
return cls(bytes(raw_secret))
def bech32(self) -> str:
converted_bits = convertbits(self.raw_secret, 8, 5)
return bech32_encode("nsec", converted_bits, Encoding.BECH32)
converted_bits = bech32.convertbits(self.raw_secret, 8, 5)
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32)
def hex(self) -> str:
return self.raw_secret.hex()
def tweak_add(self, scalar: bytes) -> bytes:
sk = coincurve.PrivateKey(self.raw_secret)
return sk.add(scalar).to_der()
sk = secp256k1.PrivateKey(self.raw_secret)
return sk.tweak_add(scalar)
def compute_shared_secret(self, public_key_hex: str) -> bytes:
pk = coincurve.PublicKey(bytes.fromhex("02" + public_key_hex))
sk = coincurve.PrivateKey(self.raw_secret)
return sk.ecdh(pk.format())
pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True)
return pk.ecdh(self.raw_secret, hashfn=copy_x)
def encrypt_message(self, message: str, public_key_hex: str) -> str:
padder = padding.PKCS7(128).padder()
@ -88,8 +83,6 @@ 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
)
@ -112,14 +105,14 @@ class PrivateKey:
return unpadded_data.decode()
def sign_message_hash(self, message_hash: bytes) -> str:
sk = coincurve.PrivateKey(self.raw_secret)
sig = sk.sign_schnorr(message_hash)
def sign_message_hash(self, hash: bytes) -> str:
sk = secp256k1.PrivateKey(self.raw_secret)
sig = sk.schnorr_sign(hash, None, raw=True)
return sig.hex()
def sign_event(self, event: Event) -> None:
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
self.encrypt_dm(event) # type: ignore
self.encrypt_dm(event)
if event.public_key is None:
event.public_key = self.public_key.hex()
event.signature = self.sign_message_hash(bytes.fromhex(event.id))
@ -128,7 +121,7 @@ class PrivateKey:
return self.raw_secret == other.raw_secret
def mine_vanity_key(prefix: str | None = None, suffix: str | None = None) -> PrivateKey:
def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
@ -144,3 +137,14 @@ def mine_vanity_key(prefix: str | None = None, suffix: str | None = None) -> Pri
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,6 +2,7 @@ import asyncio
import json
import time
from queue import Queue
from typing import List
from loguru import logger
from websocket import WebSocketApp
@ -20,14 +21,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 = Queue()
self.queue = Queue()
def connect(self):
self.ws = WebSocketApp(
@ -62,10 +63,9 @@ 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:
assert s.filters
json_str = json.dumps(["REQ", s.id, *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, sub_id: str) -> None:
def close_subscription(self, id: str) -> None:
try:
self.publish(json.dumps(["CLOSE", sub_id]))
self.publish(json.dumps(["CLOSE", 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: '{error!s}'")
logger.warning(f"[Relay: {self.url}] Error: '{str(error)}'")
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,7 +72,6 @@ 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 Optional
from typing import List
class Subscription:
def __init__(self, id: str, filters: Optional[list[str]] = None) -> None:
def __init__(self, id: str, filters: List[str] = None) -> None:
self.id = id
self.filters = filters

59
package-lock.json generated
View file

@ -1,59 +0,0 @@
{
"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"
}
}
}
}

View file

@ -1,15 +0,0 @@
{
"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"
}
}

View file

@ -1,98 +0,0 @@
[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,35 +1,29 @@
import asyncio
import json
from typing import ClassVar
from typing import Dict, List
from fastapi import WebSocket, WebSocketDisconnect
from lnbits.helpers import urlsafe_short_hash
from loguru import logger
from .nostr.client.client import NostrClient
from lnbits.helpers import urlsafe_short_hash
# 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: ClassVar[dict[str, list[EventMessage]]] = {}
received_subscription_notices: ClassVar[list[NoticeMessage]] = []
received_subscription_eosenotices: ClassVar[dict[str, EndOfStoredEventsMessage]] = (
{}
)
received_subscription_events: dict[str, List[EventMessage]] = {}
received_subscription_notices: list[NoticeMessage] = []
received_subscription_eosenotices: dict[str, EndOfStoredEventsMessage] = {}
def __init__(self, websocket: WebSocket):
self.connected: bool = True
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):
@ -48,7 +42,7 @@ class NostrRouter:
pass
try:
await self.websocket.close(reason="Websocket connection closed")
await self.websocket.close()
except Exception as _:
pass
@ -67,7 +61,7 @@ class NostrRouter:
try:
await self._handle_client_to_nostr(json_str)
except Exception as e:
logger.debug(f"Failed to handle client message: '{e!s}'.")
logger.debug(f"Failed to handle client message: '{str(e)}'.")
async def _nostr_to_client(self):
"""Sends responses from relays back to the client."""
@ -76,10 +70,10 @@ class NostrRouter:
await self._handle_subscriptions()
self._handle_notices()
except Exception as e:
logger.debug(f"Failed to handle response for client: '{e!s}'.")
await asyncio.sleep(1)
logger.debug(f"Failed to handle response for client: '{str(e)}'.")
await asyncio.sleep(0.1)
async def _handle_subscriptions(self):
for s in self.subscriptions:
if s in NostrRouter.received_subscription_events:
@ -111,19 +105,15 @@ class NostrRouter:
# this reconstructs the original response from the relay
# reconstruct original subscription id
s_original = self.original_subscription_ids[s]
event_to_forward = json.dumps(
["EVENT", s_original, json.loads(event_json)]
)
event_to_forward = f"""["EVENT", "{s_original}", {event_json}]"""
await self.websocket.send_text(event_to_forward)
except Exception as e:
logger.warning(
f"[NOSTRCLIENT] Error in _handle_received_subscription_events: {e}"
)
logger.debug(e) # there are 2900 errors here
def _handle_notices(self):
while len(NostrRouter.received_subscription_notices):
my_event = NostrRouter.received_subscription_notices.pop(0)
logger.debug(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']")
logger.info(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)
@ -146,7 +136,6 @@ 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:]
@ -165,11 +154,5 @@ 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.info(f"Failed to unsubscribe from '{subscription_id}.'")
logger.debug(f"Failed to unsubscribe from '{subscription_id}.'")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

View file

@ -3,16 +3,17 @@ 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, nostr_client
from .router import NostrRouter
async def init_relays():
# get relays from db
relays = await get_relays()
# set relays and connect to them
valid_relays = [r.url for r in relays if r.url]
valid_relays = list(set([r.url for r in relays if r.url]))
nostr_client.reconnect(valid_relays)
@ -24,36 +25,38 @@ 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: '{e!s}'.")
logger.warning(f"Cannot restart relays: '{str(e)}'.")
async def subscribe_events():
while not [r.connected for r in nostr_client.relay_manager.relays.values()]:
while not any([r.connected for r in nostr_client.relay_manager.relays.values()]):
await asyncio.sleep(2)
def callback_events(event_message: EventMessage):
sub_id = event_message.subscription_id
def callback_events(eventMessage: EventMessage):
sub_id = eventMessage.subscription_id
if sub_id not in NostrRouter.received_subscription_events:
NostrRouter.received_subscription_events[sub_id] = [event_message]
NostrRouter.received_subscription_events[sub_id] = [eventMessage]
return
# do not add duplicate events (by event id)
ids = [e.event_id for e in NostrRouter.received_subscription_events[sub_id]]
if event_message.event_id in ids:
ids = set(
[e.event_id for e in NostrRouter.received_subscription_events[sub_id]]
)
if eventMessage.event_id in ids:
return
NostrRouter.received_subscription_events[sub_id].append(event_message)
NostrRouter.received_subscription_events[sub_id].append(eventMessage)
def callback_notices(notice_message: NoticeMessage):
if notice_message not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(notice_message)
def callback_notices(noticeMessage: NoticeMessage):
if noticeMessage not in NostrRouter.received_subscription_notices:
NostrRouter.received_subscription_notices.append(noticeMessage)
def callback_eose_notices(event_message: EndOfStoredEventsMessage):
sub_id = event_message.subscription_id
def callback_eose_notices(eventMessage: EndOfStoredEventsMessage):
sub_id = eventMessage.subscription_id
if sub_id in NostrRouter.received_subscription_eosenotices:
return
NostrRouter.received_subscription_eosenotices[sub_id] = event_message
NostrRouter.received_subscription_eosenotices[sub_id] = eventMessage
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">
<div class="col-12 col-md-7 q-pa-md">
<div class="row q-pa-md">
<div class="col-9">
<q-input
outlined
v-model="relayToAdd"
@ -14,14 +14,14 @@
label="Relay URL"
></q-input>
</div>
<div class="col-6 col-md-3 q-pa-md">
<div class="col-3">
<q-btn-dropdown
unelevated
split
color="primary"
class="float-left"
class="float-right"
type="submit"
label="Add Relay"
label="Add Relay X"
>
<q-item
v-for="relay in predefinedRelays"
@ -36,15 +36,6 @@
</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>
@ -71,7 +62,7 @@
<q-table
flat
dense
:rows="nostrrelayLinks"
:data="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
@ -344,39 +335,16 @@
</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,
@ -390,7 +358,7 @@
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.date.formatDate(
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
@ -398,7 +366,7 @@
return obj
}
window.app = Vue.createApp({
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
@ -412,10 +380,6 @@
show: false,
data: null
},
config: {
showDialog: false,
data: {}
},
testData: {
show: false,
wsConnection: null,
@ -466,7 +430,8 @@
predefinedRelays: [
'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net',
'wss://relay.nostrconnect.com',
'wss://nostr.zebedee.cloud',
'wss://nodestr.fmt.wiz.biz',
'wss://nostr.oxtr.dev',
'wss://nostr.wine'
]
@ -476,7 +441,11 @@
getRelays: function () {
var self = this
LNbits.api
.request('GET', '/nostrclient/api/v1/relays')
.request(
'GET',
'/nostrclient/api/v1/relays?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey
)
.then(function (response) {
if (response.data) {
response.data.map(maplrelays)
@ -504,9 +473,12 @@
console.log('ADD RELAY ' + this.relayToAdd)
let that = this
LNbits.api
.request('POST', '/nostrclient/api/v1/relay', null, {
url: this.relayToAdd
})
.request(
'POST',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: this.relayToAdd}
)
.then(function (response) {
console.log('response:', response)
if (response.data) {
@ -533,7 +505,12 @@
},
deleteRelay(url) {
LNbits.api
.request('DELETE', '/nostrclient/api/v1/relay', null, {url: url})
.request(
'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: url}
)
.then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
if (relayIndex !== -1) {
@ -545,31 +522,6 @@
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()
@ -609,8 +561,8 @@
try {
const {data} = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/relay/test',
null,
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{
sender_private_key: this.testData.senderPrivateKey,
reciever_public_key: this.testData.recieverPublicKey,
@ -690,9 +642,9 @@
},
sleep: ms => new Promise(r => setTimeout(r, ms))
},
created: async function () {
created: function () {
var self = this
this.getRelays()
await this.getConfig()
setInterval(this.getRelays, 5000)
}
})

View file

View file

@ -1,11 +0,0 @@
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

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,17 @@
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 fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_admin
from lnbits.helpers import template_renderer
nostrclient_generic_router = APIRouter()
from . import nostr_renderer, nostrclient_ext
templates = Jinja2Templates(directory="templates")
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)
@nostrclient_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_admin)):
return nostr_renderer().TemplateResponse(
"nostrclient/index.html", {"request": request, "user": user.json()}
"nostrclient/index.html", {"request": request, "user": user.dict()}
)

View file

@ -1,29 +1,27 @@
import asyncio
from http import HTTPStatus
from typing import List
from fastapi import APIRouter, Depends, HTTPException, WebSocket
from lnbits.decorators import check_admin
from lnbits.helpers import decrypt_internal_message, urlsafe_short_hash
from fastapi import Depends, WebSocket
from loguru import logger
from starlette.exceptions import HTTPException
from .crud import (
add_relay,
create_config,
delete_relay,
get_config,
get_relays,
update_config,
)
from lnbits.decorators import check_admin
from lnbits.helpers import urlsafe_short_hash
from . import nostr_client, nostrclient_ext, scheduled_tasks
from .crud import add_relay, delete_relay, get_relays
from .helpers import normalize_public_key
from .models import Config, Relay, RelayStatus, TestMessage, TestMessageResponse
from .models import Relay, TestMessage, TestMessageResponse
from .nostr.key import EncryptedDirectMessage, PrivateKey
from .router import NostrRouter, all_routers, nostr_client
from .router import NostrRouter
nostrclient_api_router = APIRouter()
# we keep this in
all_routers: list[NostrRouter] = []
@nostrclient_api_router.get("/api/v1/relays", dependencies=[Depends(check_admin)])
async def api_get_relays() -> list[Relay]:
@nostrclient_ext.get("/api/v1/relays")
async def api_get_relays() -> List[Relay]:
relays = []
for url, r in nostr_client.relay_manager.relays.items():
relay_id = urlsafe_short_hash()
@ -32,13 +30,13 @@ async def api_get_relays() -> list[Relay]:
id=relay_id,
url=url,
connected=r.connected,
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,
),
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,
},
ping=r.ping,
active=True,
)
@ -46,10 +44,10 @@ async def api_get_relays() -> list[Relay]:
return relays
@nostrclient_api_router.post(
@nostrclient_ext.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."
@ -67,7 +65,7 @@ async def api_add_relay(relay: Relay) -> list[Relay]:
return await get_relays()
@nostrclient_api_router.delete(
@nostrclient_ext.delete(
"/api/v1/relay", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
async def api_delete_relay(relay: Relay) -> None:
@ -80,7 +78,7 @@ async def api_delete_relay(relay: Relay) -> None:
await delete_relay(relay)
@nostrclient_api_router.put(
@nostrclient_ext.put(
"/api/v1/relay/test", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
)
async def api_test_endpoint(data: TestMessage) -> TestMessageResponse:
@ -104,76 +102,50 @@ 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_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()
all_routers.append(router)
# we kill this websocket and the subscriptions
# if the user disconnects and thus `connected==False`
while router.connected:
await asyncio.sleep(10)
@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.debug(e)
logger.error(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
nostr_client.close()
for scheduled_task in scheduled_tasks:
try:
scheduled_task.cancel()
except Exception as ex:
logger.warning(ex)
return {"success": True}
@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_ext.websocket("/api/v1/relay")
async def ws_relay(websocket: WebSocket) -> None:
"""Relay multiplexer: one client (per endpoint) <-> multiple relays"""
await websocket.accept()
router = NostrRouter(websocket)
router.start()
all_routers.append(router)
# we kill this websocket and the subscriptions
# if the user disconnects and thus `connected==False`
while router.connected:
await asyncio.sleep(10)
await router.stop()
all_routers.remove(router)
@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()