Merge pull request #118 from Routstr/cursor/discover-and-announce-routstr-providers-3b9a

Discover and announce routstr providers
This commit is contained in:
shroominic
2025-09-04 21:40:24 +01:00
committed by GitHub
11 changed files with 944 additions and 435 deletions
+4 -1
View File
@@ -33,7 +33,8 @@ sequenceDiagram
- **API Key Management** Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin/` to view balances and API keys
- **Discovery** Fetch available providers from Nostr relays
- **Discovery** Fetch available providers from Nostr relays using NIP-91 protocol
- **NIP-91 Auto-Announcement** Automatically announce this provider to Nostr relays when NSEC is provided
- **Docker Support** Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
## Getting Started
@@ -86,6 +87,8 @@ This builds the image and also starts a Tor container exposing the API as a hidd
The most common settings are shown below. See `.env.example` for the full list.
### Core Settings
- `UPSTREAM_BASE_URL` URL of the OpenAI-compatible service
- `UPSTREAM_API_KEY` API key for the upstream service (optional)
- `MODEL_BASED_PRICING` Set to `true` to use pricing from `models.json`
+1
View File
@@ -6,6 +6,7 @@ services:
volumes:
- .:/app
- ./logs:/app/logs
- tor-data:/var/lib/tor:ro
env_file:
- .env
environment:
+11 -5
View File
@@ -5,6 +5,7 @@ Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays)
## Overview
Nostr integration provides:
- **Decentralized Discovery**: Find providers through relay network
- **Cryptographic Identity**: Providers identified by public keys
- **Real-time Updates**: Live provider status and pricing
@@ -34,6 +35,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien
### Setting Up Nostr Identity
1. **Generate Nostr Keys**
```bash
# Using nostril or similar tool
nostril --generate-keypair
@@ -44,6 +46,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien
```
2. **Configure Environment**
```bash
# .env
NPUB=npub1xyz... # Your public key
@@ -104,7 +107,7 @@ DEFAULT_RELAYS = [
]
# Custom relay configuration
NOSTR_RELAYS=wss://relay1.com,wss://relay2.com
RELAYS=wss://relay1.com,wss://relay2.com
```
## Client Discovery
@@ -276,6 +279,7 @@ def select_provider(
### Automatic Updates
Routstr publishes updates when:
- Node starts up
- Configuration changes
- Models are added/removed
@@ -295,7 +299,7 @@ async def publish_provider_info():
pricing=get_current_pricing()
)
await publish_to_relays(event, NOSTR_RELAYS)
await publish_to_relays(event, RELAYS)
```
### Event Lifecycle
@@ -315,7 +319,7 @@ async def update_nostr_presence():
async def remove_nostr_presence():
"""Remove provider from discovery."""
deletion_event = create_deletion_event()
await publish_to_relays(deletion_event, NOSTR_RELAYS)
await publish_to_relays(deletion_event, RELAYS)
```
## Security Considerations
@@ -323,6 +327,7 @@ async def remove_nostr_presence():
### Key Management
1. **Secure Storage**
```python
# Never log private keys
SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD']
@@ -335,6 +340,7 @@ async def remove_nostr_presence():
```
2. **Key Rotation**
```bash
# Generate new keys
nostril --generate-keypair
@@ -524,7 +530,7 @@ async def debug_discovery():
issues = []
# Check relay connectivity
for relay in NOSTR_RELAYS:
for relay in RELAYS:
if not await can_connect_to_relay(relay):
issues.append(f"Cannot connect to {relay}")
@@ -589,4 +595,4 @@ wscat -c wss://relay.damus.io
- [Tor Support](tor.md) - Anonymous provider access
- [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies
- [API Reference](../api/endpoints.md) - Discovery API details
- [API Reference](../api/endpoints.md) - Discovery API details
+2
View File
@@ -16,6 +16,8 @@ dependencies = [
"cashu",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
"nostr>=0.0.2",
]
[dependency-groups]
+9 -4
View File
@@ -293,14 +293,19 @@ def setup_logging() -> None:
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"websockets": {
"level": "WARNING",
"handlers": [],
"propagate": False,
},
"uvicorn.access": {
"level": log_level, # Use the configured log level instead of WARNING
"handlers": handlers, # Use both console and file handlers
"level": "WARNING",
"handlers": ["file"],
"propagate": False,
},
"uvicorn.error": {
"level": log_level, # Use the configured log level
"handlers": handlers, # Use both console and file handlers
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
+14 -1
View File
@@ -9,7 +9,8 @@ from fastapi.responses import RedirectResponse
from starlette.exceptions import HTTPException
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_router
from ..discovery import providers_cache_refresher, providers_router
from ..nip91 import announce_provider
from ..payment.models import MODELS, models_router, update_sats_pricing
from ..proxy import proxy_router
from ..wallet import periodic_payout
@@ -32,6 +33,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = None
payout_task = None
nip91_task = None
providers_task = None
try:
# Run database migrations on startup
@@ -46,6 +49,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = asyncio.create_task(update_sats_pricing())
payout_task = asyncio.create_task(periodic_payout())
nip91_task = asyncio.create_task(announce_provider())
providers_task = asyncio.create_task(providers_cache_refresher())
yield
@@ -62,6 +67,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
if providers_task is not None:
providers_task.cancel()
try:
tasks_to_wait = []
@@ -69,6 +78,10 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if providers_task is not None:
tasks_to_wait.append(providers_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
+222 -98
View File
@@ -15,6 +15,10 @@ logger = get_logger(__name__)
providers_router = APIRouter(prefix="/v1/providers")
# In-memory providers cache and lock
_PROVIDERS_CACHE: list[dict[str, Any]] = []
_PROVIDERS_CACHE_LOCK = asyncio.Lock()
def generate_subscription_id() -> str:
"""Generate a random subscription ID."""
@@ -28,14 +32,14 @@ async def query_nostr_relay_for_providers(
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for provider announcements using RIP-02 spec.
Searches for kind 31338 events (Routstr Provider Announcements).
Query a Nostr relay for provider announcements.
Searches for NIP-91 (kind:38421) events.
"""
events = []
# Build filter according to RIP-02 spec
# Build filter for NIP-91 events
filter_obj: dict[str, Any] = {
"kinds": [31338], # RIP-02 Provider Announcement events
"kinds": [38421], # NIP-91 Provider Announcements
"limit": limit,
}
@@ -47,8 +51,8 @@ async def query_nostr_relay_for_providers(
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
logger.debug("Connected to relay, searching for kind 31338 events")
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
logger.debug("Connected to relay, searching for NIP-91 events (kind 38421)")
await websocket.send(req_message)
while True:
@@ -64,7 +68,13 @@ async def query_nostr_relay_for_providers(
logger.debug("Received EOSE message")
break
elif data[0] == "NOTICE":
logger.warning(f"Relay notice: {data[1]}")
try:
msg = str(data[1])
if len(msg) > 200:
msg = msg[:200] + "..."
logger.debug(f"Relay notice: {msg}")
except Exception:
logger.debug("Relay notice received")
except asyncio.TimeoutError:
logger.debug("Timeout waiting for message")
@@ -76,7 +86,7 @@ async def query_nostr_relay_for_providers(
await websocket.send(json.dumps(["CLOSE", sub_id]))
except Exception as e:
logger.error(f"Query failed: {e}")
logger.debug(f"Query failed: {type(e).__name__}")
logger.info(f"Query complete. Found {len(events)} provider announcements")
return events
@@ -84,17 +94,19 @@ async def query_nostr_relay_for_providers(
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
"""
Parse a kind 31338 provider announcement event according to RIP-02 spec.
Parse provider announcement events.
Handles NIP-91 (kind:38421) format.
Returns structured provider data or None if invalid.
"""
try:
# Extract required tags according to RIP-02
tags = event.get("tags", [])
kind = event.get("kind")
# Find required tags
endpoint_url = None
provider_name = None
# Common fields
d_tag = None
endpoint_urls = []
provider_name = None
endpoint_url = None
for tag in tags:
if len(tag) >= 2:
@@ -105,8 +117,8 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
elif tag[0] == "d":
d_tag = tag[1]
# Validate required fields
if not endpoint_url or not provider_name or not d_tag:
# Early validation only applies to legacy/other kinds, not NIP-91
if kind != 38421 and (not endpoint_url or not provider_name or not d_tag):
logger.warning(
f"Invalid provider announcement - missing required tags: {event['id']}"
)
@@ -114,46 +126,194 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
# Extract optional tags
description = None
contact = None
pricing_url = None
supported_models = []
mint_urls = []
version = None
for tag in tags:
if len(tag) >= 2:
if tag[0] == "description":
description = tag[1]
elif tag[0] == "contact":
contact = tag[1]
elif tag[0] == "pricing":
pricing_url = tag[1]
elif tag[0] == "model":
supported_models.append(tag[1])
# Parse NIP-91 format
if kind == 38421: # NIP-91 format
for tag in tags:
if len(tag) >= 2:
if tag[0] == "d":
d_tag = tag[1]
elif tag[0] == "u":
endpoint_urls.append(tag[1])
elif tag[0] == "mint":
mint_urls.append(tag[1])
elif tag[0] == "version":
version = tag[1]
# Parse metadata from content for NIP-91
content = event.get("content", "")
if content:
try:
metadata = json.loads(content)
provider_name = metadata.get("name", "Unknown Provider")
description = metadata.get("about")
except (json.JSONDecodeError, TypeError):
provider_name = "Unknown Provider"
else:
provider_name = "Unknown Provider"
# Use first URL as primary endpoint
endpoint_url = endpoint_urls[0] if endpoint_urls else None
# Validate NIP-91 required fields
if not endpoint_url or not d_tag:
logger.warning(
f"Invalid NIP-91 announcement - missing required fields: {event['id']}"
)
return None
else:
logger.warning(
f"Unknown event kind when parsing provider announcement: {kind}"
)
return None
return {
"id": event["id"],
"id": d_tag,
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"d_tag": d_tag,
"kind": kind,
"endpoint_url": endpoint_url,
"endpoint_urls": endpoint_urls, # All URLs for NIP-91
"name": provider_name,
"description": description,
"contact": contact,
"pricing_url": pricing_url,
"supported_models": supported_models,
"mint_urls": mint_urls,
"version": version,
"content": event.get("content", ""),
}
except Exception as e:
logger.error(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
logger.error(
f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}"
)
return None
async def get_cache() -> list[dict[str, Any]]:
return [] # TODO: Implement cache
async with _PROVIDERS_CACHE_LOCK:
return list(_PROVIDERS_CACHE)
def _get_discovery_relays() -> list[str]:
relays_env = os.getenv("RELAYS") or ""
discovery_relays = [r.strip() for r in relays_env.split(",") if r.strip()]
if not discovery_relays:
discovery_relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
return discovery_relays
async def _discover_providers(pubkey: str | None = None) -> list[dict[str, Any]]:
discovery_relays = _get_discovery_relays()
tasks = [
query_nostr_relay_for_providers(relay_url=r, pubkey=pubkey, limit=100)
for r in discovery_relays
]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_events: list[dict[str, Any]] = []
event_ids: set[str] = set()
for res in results:
if isinstance(res, BaseException):
logger.error(f"Relay query failed: {res}")
continue
if isinstance(res, list):
for event in res:
# Filter out localhost announcements
try:
tags = event.get("tags", [])
is_localhost = any(
isinstance(tag, list)
and len(tag) >= 2
and tag[0] == "u"
and tag[1] == "http://localhost:8000"
for tag in tags
)
if is_localhost:
logger.debug(
f"Skipping localhost provider event: {event.get('id', 'unknown')}"
)
continue
except Exception:
# If tags are malformed, fall through to normal handling
pass
if (eid := event.get("id")) and eid not in event_ids:
event_ids.add(eid)
all_events.append(event)
else:
logger.error(f"Unexpected relay result type: {type(res)}")
providers: list[dict[str, Any]] = []
seen_endpoints: set[str] = set()
for event in all_events:
parsed = parse_provider_announcement(event)
if parsed and (eu := parsed.get("endpoint_url")) and eu not in seen_endpoints:
seen_endpoints.add(eu)
providers.append(parsed)
random.shuffle(providers)
return providers[:42]
async def refresh_providers_cache(pubkey: str | None = None) -> None:
try:
providers = await _discover_providers(pubkey=pubkey)
health_tasks = [
fetch_provider_health(provider["endpoint_url"]) for provider in providers
]
health_results = await asyncio.gather(*health_tasks, return_exceptions=True)
new_cache: list[dict[str, Any]] = []
for provider, hr in zip(providers, health_results):
if isinstance(hr, Exception):
health: dict[str, Any] = {
"status_code": 500,
"endpoint": "error",
"json": {"error": str(hr)},
}
else:
health = hr # type: ignore[assignment]
new_cache.append({"provider": provider, "health": health})
async with _PROVIDERS_CACHE_LOCK:
_PROVIDERS_CACHE.clear()
_PROVIDERS_CACHE.extend(new_cache)
logger.info(
f"Providers cache refreshed with {len(new_cache)} entries (limit 42)"
)
except Exception as e:
logger.error(f"Failed to refresh providers cache: {e}")
async def providers_cache_refresher(
interval_seconds: int | None = None, pubkey: str | None = None
) -> None:
if interval_seconds is None:
try:
interval_seconds = int(
os.getenv("PROVIDERS_REFRESH_INTERVAL_SECONDS", "300")
)
except ValueError:
interval_seconds = 300
await refresh_providers_cache(pubkey=pubkey)
while True:
try:
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
break
await refresh_providers_cache(pubkey=pubkey)
async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
"""Check if a provider endpoint is healthy by making a GET request."""
"""Fetch provider health and info, preferring /v1/info for models and pricing."""
try:
# Determine if we need Tor proxy based on .onion domain
is_onion = ".onion" in endpoint_url
@@ -170,7 +330,20 @@ async def fetch_provider_health(endpoint_url: str) -> dict[str, Any]:
follow_redirects=True,
proxies=proxies, # type: ignore[arg-type]
) as client:
# Try to fetch models endpoint first (common for AI providers)
# Prefer provider's /v1/info for full details
info_url = f"{endpoint_url.rstrip('/')}/v1/info"
try:
response = await client.get(info_url)
if response.status_code == 200:
return {
"status_code": response.status_code,
"endpoint": "info",
"json": response.json(),
}
except Exception:
pass
# Fallback to /v1/models
models_url = f"{endpoint_url.rstrip('/')}/v1/models"
try:
response = await client.get(models_url)
@@ -208,65 +381,16 @@ async def get_providers(
include_json: bool = False, pubkey: str | None = None
) -> dict[str, list[dict[str, Any]]]:
"""
Discover Routstr providers using RIP-02 specification.
Searches for kind 31338 provider announcement events on Nostr relays.
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
Return cached providers. If include_json, return provider+health; otherwise provider only.
Optional filter by pubkey.
"""
# Default relays for provider discovery
discovery_relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
all_events = []
event_ids = set() # To avoid duplicates
# Query multiple relays for provider announcements
for relay_url in discovery_relays:
logger.info(f"Querying relay for providers: {relay_url}")
try:
events = await query_nostr_relay_for_providers(
relay_url=relay_url,
pubkey=pubkey,
limit=100,
)
# Add unique events
for event in events:
if event["id"] not in event_ids:
event_ids.add(event["id"])
all_events.append(event)
logger.info(f"Got {len(events)} provider announcements from {relay_url}")
except Exception as e:
logger.error(f"Failed to query {relay_url}: {e}")
continue
logger.info(f"Found {len(all_events)} total unique provider announcements")
# Parse provider announcements according to RIP-02
providers = []
for event in all_events:
parsed_provider = parse_provider_announcement(event)
if parsed_provider:
providers.append(parsed_provider)
logger.info(f"Parsed {len(providers)} valid provider announcements")
# Check provider health if requested
healthy_providers: list[dict[str, Any]] = []
for provider in providers:
endpoint_url = provider["endpoint_url"]
if include_json:
health_check = await fetch_provider_health(endpoint_url)
provider_data = {"provider": provider, "health": health_check}
healthy_providers.append(provider_data)
else:
# Just return the provider info without health check
healthy_providers.append(provider)
return {"providers": healthy_providers}
cache = await get_cache()
if not cache:
await refresh_providers_cache(pubkey=pubkey)
cache = await get_cache()
if pubkey:
cache = [c for c in cache if c.get("provider", {}).get("pubkey") == pubkey]
if include_json:
return {"providers": cache}
providers_only = [c["provider"] for c in cache]
return {"providers": providers_only}
+565
View File
@@ -0,0 +1,565 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
import asyncio
import json
import os
import random
import ssl
import time
from typing import Any, cast
from nostr.event import Event
from nostr.filter import Filter, Filters
from nostr.key import PrivateKey
from nostr.message_type import ClientMessageType
from nostr.relay_manager import RelayManager
from .core import get_logger
logger = get_logger(__name__)
def get_app_version() -> str | None:
try:
from .core.main import __version__ as imported_version
return imported_version
except Exception:
return None
def _event_to_dict(ev: Event) -> dict[str, Any]:
return {
"id": ev.id,
"pubkey": ev.public_key,
"created_at": ev.created_at,
"kind": int(ev.kind) if not isinstance(ev.kind, int) else ev.kind,
"tags": ev.tags,
"content": ev.content,
"sig": ev.signature,
}
def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
"""
Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex).
Args:
nsec: Nostr private key in nsec format or hex format
Returns:
Tuple of (private_key_hex, public_key_hex) or None if invalid
"""
try:
if nsec.startswith("nsec"):
pk = PrivateKey.from_nsec(nsec)
return (pk.hex(), pk.public_key.hex())
if len(nsec) == 64:
pk = PrivateKey(bytes.fromhex(nsec))
return (pk.hex(), pk.public_key.hex())
logger.error(f"Invalid private key format/length: {len(nsec)}")
return None
except Exception as e:
logger.error(f"Failed to convert nsec to keypair: {e}")
return None
def create_nip91_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
mint_urls: list[str] | None = None,
version: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
provider_id: Unique identifier for this provider (d tag)
endpoint_urls: List of URLs to connect to the provider
mint_urls: Optional list of ecash mint URLs for payments
version: Provider software version
metadata: Optional metadata dictionary (name, picture, about, etc.)
Returns:
Complete signed nostr event as a dict ready for publishing
"""
pk = PrivateKey(bytes.fromhex(private_key_hex))
tags = [["d", provider_id]]
for url in endpoint_urls:
tags.append(["u", url])
if mint_urls:
for m in mint_urls:
if m:
tags.append(["mint", m])
if version:
tags.append(["version", version])
content = json.dumps(metadata, separators=(",", ":")) if metadata else ""
ev = Event(pk.public_key.hex(), content, kind=38421, tags=tags)
pk.sign_event(ev)
return _event_to_dict(ev)
def _get_tag_values(event: dict[str, Any], key: str) -> list[str]:
tags = event.get("tags", [])
values: list[str] = []
for tag in tags:
if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2:
values.append(tag[1])
return values
def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None:
values = _get_tag_values(event, key)
return values[0] if values else None
def _parse_content_json(content: str) -> dict[str, Any]:
if not content:
return {}
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
if a.get("kind") != b.get("kind"):
return False
if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"):
return False
urls_a = set(_get_tag_values(a, "u"))
urls_b = set(_get_tag_values(b, "u"))
if urls_a != urls_b:
return False
mints_a = set(_get_tag_values(a, "mint"))
mints_b = set(_get_tag_values(b, "mint"))
if mints_a != mints_b:
return False
if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"):
return False
content_a = _parse_content_json(cast(str, a.get("content", "")))
content_b = _parse_content_json(cast(str, b.get("content", "")))
if content_a != content_b:
return False
return True
async def query_nip91_events(
relay_url: str,
pubkey: str,
provider_id: str | None = None,
timeout: int = 30,
) -> tuple[list[dict[str, Any]], bool]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421) via nostr library.
Returns a tuple of (events, ok) where ok indicates whether the relay interaction
succeeded without transport-level errors.
"""
def _sync_query() -> tuple[list[dict[str, Any]], bool]:
rm = RelayManager()
rm.add_relay(relay_url)
events_out: list[dict[str, Any]] = []
ok = True
try:
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
time.sleep(1.0)
flt = Filter(kinds=[38421], authors=[pubkey], limit=10)
filters = Filters([flt])
sub_id = f"nip91_{int(time.time())}"
rm.add_subscription(sub_id, filters)
req: list[Any] = [ClientMessageType.REQUEST, sub_id]
req.extend(filters.to_json_array())
rm.publish_message(json.dumps(req))
start = time.time()
last_event_ts = start
while time.time() - start < timeout:
drained = False
while rm.message_pool.has_events():
drained = True
ev_msg = rm.message_pool.get_event()
ev = ev_msg.event
ev_dict = _event_to_dict(ev)
if provider_id is not None:
tags = ev_dict.get("tags", [])
if not any(
isinstance(t, list)
and len(t) >= 2
and t[0] == "d"
and t[1] == provider_id
for t in tags
):
continue
events_out.append(ev_dict)
logger.debug(
f"Found existing NIP-91 event: {ev_dict.get('id', '')}"
)
if drained:
last_event_ts = time.time()
while rm.message_pool.has_notices():
notice = rm.message_pool.get_notice()
try:
content = getattr(notice, "content", notice)
s = str(content)
if len(s) > 200:
s = s[:200] + "..."
logger.debug(f"Relay notice: {s}")
except Exception:
pass
if time.time() - last_event_ts > 2.5:
break
time.sleep(0.1)
except Exception as e:
ok = False
logger.debug(f"Failed to query relay {relay_url}: {type(e).__name__}")
finally:
try:
rm.close_connections()
except Exception:
pass
return events_out, ok
return await asyncio.to_thread(_sync_query)
def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
"""Discover onion URL by reading Tor hidden service hostname files.
Tries common paths first, then scans recursively for any 'hostname' file.
Returns an http URL like 'http://<host>.onion' if found.
"""
common_candidates = [
os.path.join(base_dir, "hs", "router", "hostname"),
os.path.join(base_dir, "hs", "ROUTER", "hostname"),
os.path.join(base_dir, "hidden_service", "hostname"),
]
for candidate in common_candidates:
try:
with open(candidate, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
pass
try:
for root, _dirs, files in os.walk(base_dir):
if "hostname" in files:
path = os.path.join(root, "hostname")
try:
with open(path, "r", encoding="utf-8") as f:
host = f.readline().strip()
if host and host.endswith(".onion"):
return f"http://{host}"
except Exception:
continue
except Exception:
pass
return None
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
explicit = os.getenv("PROVIDER_ID") or os.getenv("NIP91_PROVIDER_ID")
if explicit:
logger.info(f"Using configured provider_id from env: {explicit}")
return explicit
latest_event: dict[str, Any] | None = None
latest_ts = -1
for relay_url in relay_urls:
try:
events, _ok = await query_nip91_events(relay_url, public_key_hex, None)
for ev in events:
ts = int(ev.get("created_at", 0))
if ts > latest_ts:
latest_event = ev
latest_ts = ts
except Exception:
continue
existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None
if existing_d:
logger.info(f"Reusing existing provider_id from relay: {existing_d}")
return existing_d
fallback = public_key_hex[:12]
logger.info(f"No existing provider_id found; using fallback: {fallback}")
return fallback
async def publish_to_relay(
relay_url: str,
event: dict[str, Any],
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay via nostr library.
"""
def _sync_publish() -> bool:
rm = RelayManager()
rm.add_relay(relay_url)
try:
rm.open_connections({"cert_reqs": ssl.CERT_NONE})
time.sleep(1.0)
# Publish the event as-is via publish_message to preserve signature
rm.publish_message(json.dumps(["EVENT", event]))
logger.debug(f"Sent NIP-91 event {event.get('id', '')} to {relay_url}")
time.sleep(1.0)
return True
except Exception as e:
logger.debug(f"Failed to publish to {relay_url}: {type(e).__name__}")
return False
finally:
try:
rm.close_connections()
except Exception:
pass
return await asyncio.to_thread(_sync_publish)
async def announce_provider() -> None:
"""
Background task to announce this Routstr provider to Nostr relays.
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment (use NSEC only)
nsec = os.getenv("NSEC")
if not nsec:
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
return
private_key_hex, public_key_hex = keypair
logger.info(f"Using Nostr pubkey: {public_key_hex}")
# Configure relays first (RELAYS only)
relay_urls_env = os.getenv("RELAYS") or ""
logger.debug(f"Configured relays: {relay_urls_env}")
relay_urls = [url.strip() for url in relay_urls_env.split(",") if url.strip()]
if not relay_urls:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://nos.lol",
]
# Determine a stable provider_id
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
logger.info(f"Using provider_id: {provider_id}")
# Core settings only (no ROUTSTR_* vars)
base_url = os.getenv("HTTP_URL")
onion_url = os.getenv("ONION_URL")
if not onion_url:
discovered = discover_onion_url_from_tor()
if discovered:
onion_url = discovered
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
provider_name = os.getenv("NAME", "Routstr Proxy")
provider_about = os.getenv("DESCRIPTION", "Privacy-preserving AI proxy via Nostr")
# Mint URLs optional: include all CASHU_MINTS entries if available
cashu_mints = [
m.strip() for m in os.getenv("CASHU_MINTS", "").split(",") if m.strip()
]
mint_urls = cashu_mints if cashu_mints else None
# Build endpoint URLs (skip defaults like localhost)
endpoint_urls: list[str] = []
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
endpoint_urls.append(base_url.strip())
if onion_url and onion_url.strip():
ou = onion_url.strip()
if ou.endswith(".onion") and not (
ou.startswith("http://") or ou.startswith("https://")
):
ou = f"http://{ou}"
endpoint_urls.append(ou)
if not endpoint_urls:
logger.warning(
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
)
return
# Build metadata
metadata = {
"name": provider_name,
"about": provider_about,
}
# Create the candidate event that we would publish
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
metadata=metadata,
)
# Backoff configuration and state
backoff_base = float(os.getenv("NIP91_BACKOFF_BASE_SECONDS", "5"))
backoff_max = float(os.getenv("NIP91_BACKOFF_MAX_SECONDS", "900"))
backoff_jitter_ratio = float(os.getenv("NIP91_BACKOFF_JITTER_RATIO", "0.2"))
relay_next_allowed: dict[str, float] = {}
relay_current_delay: dict[str, float] = {}
def _should_skip(relay: str) -> bool:
return time.time() < relay_next_allowed.get(relay, 0.0)
def _register_success(relay: str) -> None:
relay_current_delay[relay] = 0.0
relay_next_allowed[relay] = time.time()
def _register_failure(relay: str) -> None:
previous = relay_current_delay.get(relay, 0.0)
delay = backoff_base if previous <= 0.0 else min(backoff_max, previous * 2.0)
jitter = delay * backoff_jitter_ratio * (2.0 * random.random() - 1.0)
scheduled = time.time() + max(0.0, delay + jitter)
relay_current_delay[relay] = delay
relay_next_allowed[relay] = scheduled
logger.debug(
f"Backoff: {relay} delay={delay:.1f}s jitter={jitter:.1f}s next={int(scheduled)}"
)
# Fetch existing events for this provider_id
existing_events: list[dict[str, Any]] = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(relay_url, public_key_hex, provider_id)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
# Decide whether to publish: publish if none exist or any differ from candidate
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if not all_match:
logger.debug(
"No matching NIP-91 announcement found or differences detected; publishing update"
)
success_count = 0
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
if await publish_to_relay(relay_url, candidate_event):
_register_success(relay_url)
success_count += 1
else:
_register_failure(relay_url)
logger.info(
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
)
else:
logger.debug(
"Matching NIP-91 announcement already present; skipping publish on startup"
)
# Re-announce periodically (every 24 hours)
announcement_interval = int(
os.getenv("NIP91_ANNOUNCEMENT_INTERVAL", str(24 * 60 * 60))
)
while True:
try:
await asyncio.sleep(announcement_interval)
# Build fresh candidate event for comparison
version_str = get_app_version()
candidate_event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
mint_urls=mint_urls,
version=version_str,
metadata=metadata,
)
# Fetch existing events for this provider_id
existing_events = []
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping {relay_url} due to backoff")
continue
events, ok = await query_nip91_events(
relay_url, public_key_hex, provider_id
)
if ok:
_register_success(relay_url)
existing_events.extend(events)
else:
_register_failure(relay_url)
found_any = len(existing_events) > 0
all_match = found_any and all(
events_semantically_equal(ev, candidate_event) for ev in existing_events
)
if all_match:
logger.debug(
"Matching NIP-91 announcement already present; skipping periodic re-announce"
)
continue
logger.debug(
f"Re-announcing provider due to differences or absence: {candidate_event['id']}"
)
for relay_url in relay_urls:
if _should_skip(relay_url):
logger.debug(f"Skipping publish to {relay_url} due to backoff")
continue
ok = await publish_to_relay(relay_url, candidate_event)
if ok:
_register_success(relay_url)
else:
_register_failure(relay_url)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
break
except Exception as e:
logger.debug(f"Error in NIP-91 announcement loop: {type(e).__name__}")
# Continue running despite errors
-268
View File
@@ -1,268 +0,0 @@
#!/usr/bin/env python3
"""
Simple Python function to publish one provider listing to a nostr relay
according to the RIP-02 specification.
Based on: https://github.com/Routstr/protocol/blob/main/RIP-02.md
Event Kind: 31338 (Routstr Provider Announcements)
"""
import asyncio
import hashlib
import json
import time
from typing import Any
import secp256k1
import websockets
def create_provider_announcement_event(
private_key_hex: str,
provider_name: str,
endpoint_url: str,
d_tag: str,
description: str | None = None,
contact: str | None = None,
pricing_url: str | None = None,
supported_models: list[str] | None = None,
) -> dict[str, Any]:
"""
Create a RIP-02 compliant provider announcement event.
Args:
private_key_hex: 32-byte hex private key for signing
provider_name: Human readable name for the provider
endpoint_url: Base URL for the provider's API endpoint
d_tag: Unique identifier for this provider (required for addressable events)
description: Optional description of the provider
contact: Optional contact information
pricing_url: Optional URL to pricing information
supported_models: Optional list of supported model names
Returns:
Complete signed nostr event ready for publishing
"""
# Convert hex private key to secp256k1 PrivateKey object
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
public_key = private_key.pubkey.serialize(compressed=True)[
1:
] # Remove 0x02/0x03 prefix
# Build required tags according to RIP-02
tags = [
["d", d_tag], # Required for addressable events (kind 30000-39999)
["endpoint", endpoint_url],
["name", provider_name],
]
# Add optional tags if provided
if description:
tags.append(["description", description])
if contact:
tags.append(["contact", contact])
if pricing_url:
tags.append(["pricing", pricing_url])
if supported_models:
for model in supported_models:
tags.append(["model", model])
# Create the event structure
created_at = int(time.time())
event_data = [
0, # Reserved field
public_key.hex(), # Public key as hex
created_at, # Unix timestamp
31338, # Kind for RIP-02 Provider Announcements
tags, # Tags array
"", # Content (empty for provider announcements)
]
# Serialize event data for hashing
event_json = json.dumps(event_data, separators=(",", ":"), ensure_ascii=False)
# Calculate event ID (SHA256 hash)
event_id = hashlib.sha256(event_json.encode("utf-8")).hexdigest()
# Sign the event ID
signature = private_key.ecdsa_sign(bytes.fromhex(event_id), raw=True)
signature_der = private_key.ecdsa_serialize(signature)
# Create the final event
event = {
"id": event_id,
"pubkey": public_key.hex(),
"created_at": created_at,
"kind": 31338,
"tags": tags,
"content": "",
"sig": signature_der.hex(),
}
return event
async def publish_provider_to_relay(
relay_url: str, event: dict[str, Any], timeout: int = 30
) -> bool:
"""
Publish a provider announcement event to a nostr relay.
Args:
relay_url: WebSocket URL of the nostr relay (e.g., "wss://relay.damus.io")
event: Complete signed nostr event to publish
timeout: Connection timeout in seconds
Returns:
True if successfully published, False otherwise
"""
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
# Send EVENT message
event_message = json.dumps(["EVENT", event])
await websocket.send(event_message)
print(f"Published event {event['id']} to {relay_url}")
# Wait for OK response
try:
response = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(response)
if data[0] == "OK" and data[1] == event["id"]:
if data[2]: # True means accepted
print(
f"✅ Event accepted by relay: {data[3] if len(data) > 3 else ''}"
)
return True
else:
print(
f"❌ Event rejected by relay: {data[3] if len(data) > 3 else ''}"
)
return False
elif data[0] == "NOTICE":
print(f"📢 Relay notice: {data[1]}")
return False
else:
print(f"🤔 Unexpected response: {data}")
return False
except asyncio.TimeoutError:
print("⏰ No response from relay within timeout")
return False
except Exception as e:
print(f"💥 Failed to publish to {relay_url}: {e}")
return False
async def publish_provider_listing(
private_key_hex: str,
provider_name: str,
endpoint_url: str,
d_tag: str,
relay_urls: list[str] | None = None,
description: str | None = None,
contact: str | None = None,
pricing_url: str | None = None,
supported_models: list[str] | None = None,
) -> dict[str, bool]:
"""
Complete function to create and publish a provider listing to nostr relays.
Args:
private_key_hex: 32-byte hex private key for signing
provider_name: Human readable name for the provider
endpoint_url: Base URL for the provider's API endpoint
d_tag: Unique identifier for this provider
relay_urls: List of relay URLs to publish to (uses defaults if None)
description: Optional description of the provider
contact: Optional contact information
pricing_url: Optional URL to pricing information
supported_models: Optional list of supported model names
Returns:
Dictionary mapping relay URLs to success status
"""
# Use default relays if none provided
if relay_urls is None:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
# Create the provider announcement event
event = create_provider_announcement_event(
private_key_hex=private_key_hex,
provider_name=provider_name,
endpoint_url=endpoint_url,
d_tag=d_tag,
description=description,
contact=contact,
pricing_url=pricing_url,
supported_models=supported_models,
)
print(f"📝 Created provider announcement event: {event['id']}")
print(f"🔑 Public key: {event['pubkey']}")
print(f"🏷️ Provider: {provider_name}")
print(f"🌐 Endpoint: {endpoint_url}")
print()
# Publish to all specified relays
results = {}
tasks = []
for relay_url in relay_urls:
task = publish_provider_to_relay(relay_url, event)
tasks.append((relay_url, task))
# Execute all publishing tasks concurrently
for relay_url, task in tasks:
try:
success = await task
results[relay_url] = success
except Exception as e:
print(f"💥 Failed to publish to {relay_url}: {e}")
results[relay_url] = False
return results
# Example usage
async def main() -> None:
"""Example of how to use the provider publishing function."""
# Example private key (DO NOT use this in production!)
private_key = "3185a47e3802f956ca207b46c8d6b8b5c5dbad53a5ca29816050e9b66badc33c"
# Example provider information
provider_name = "My AI Provider"
endpoint_url = "https://api.myaiprovider.com"
d_tag = "my-ai-provider-v1" # Unique identifier
description = "High-quality AI models with competitive pricing"
contact = "admin@myaiprovider.com"
pricing_url = "https://myaiprovider.com/pricing"
supported_models = ["gpt-4o", "claude-3-sonnet", "llama-3.1-70b"]
# Publish to relays
results = await publish_provider_listing(
private_key_hex=private_key,
provider_name=provider_name,
endpoint_url=endpoint_url,
d_tag=d_tag,
description=description,
contact=contact,
pricing_url=pricing_url,
supported_models=supported_models,
)
# Print results
print("\n📊 Publishing Results:")
for relay_url, success in results.items():
status = "✅ Success" if success else "❌ Failed"
print(f" {relay_url}: {status}")
if __name__ == "__main__":
asyncio.run(main())
+95 -57
View File
@@ -9,9 +9,16 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from .utils import PerformanceValidator, ResponseValidator
@pytest.fixture(autouse=True)
def _clear_providers_cache() -> None:
_PROVIDERS_CACHE.clear()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_providers_endpoint_default_response(
@@ -26,13 +33,25 @@ async def test_providers_endpoint_default_response(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Check out this provider: http://provider1.onion",
"pubkey": "test_pubkey1",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": '{"name": "Provider 1", "about": "Test provider 1"}',
"tags": [
["d", "provider1"],
["u", "http://provider1.onion"],
],
},
{
"id": "event2",
"content": "Another provider at http://provider2.onion is good",
"pubkey": "test_pubkey2",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567891,
"content": '{"name": "Provider 2", "about": "Test provider 2"}',
"tags": [
["d", "provider2"],
["u", "http://provider2.onion"],
],
},
]
@@ -60,10 +79,11 @@ async def test_providers_endpoint_default_response(
assert "providers" in data
assert isinstance(data["providers"], list)
# In default format, should return list of provider URLs (strings)
# In default format, should return list of provider objects
for provider in data["providers"]:
assert isinstance(provider, str)
assert provider.endswith(".onion")
assert isinstance(provider, dict)
assert "endpoint_url" in provider
assert provider["endpoint_url"].endswith(".onion")
# Verify no database state changes
diff = await db_snapshot.diff()
@@ -86,8 +106,14 @@ async def test_providers_endpoint_with_include_json(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Provider info: http://test-provider.onion",
"pubkey": "test_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": '{"name": "Test Provider", "about": "A test provider"}',
"tags": [
["d", "test-provider"],
["u", "http://test-provider.onion"],
],
}
]
@@ -117,15 +143,20 @@ async def test_providers_endpoint_with_include_json(
assert "providers" in data
assert isinstance(data["providers"], list)
# With include_json=true, should return list of dictionaries
for provider in data["providers"]:
assert isinstance(provider, dict)
# Each provider should be in format {url: json_data}
assert len(provider) == 1
url = list(provider.keys())[0]
json_data = provider[url]
assert url.endswith(".onion")
assert isinstance(json_data, dict)
# With include_json=true, should return list of dictionaries with provider and health info
for provider_data in data["providers"]:
assert isinstance(provider_data, dict)
# Each provider should have 'provider' and 'health' keys
assert "provider" in provider_data
assert "health" in provider_data
provider_info = provider_data["provider"]
assert "endpoint_url" in provider_info
assert provider_info["endpoint_url"].endswith(".onion")
health_info = provider_data["health"]
assert isinstance(health_info, dict)
assert "status_code" in health_info
# Verify no database state changes
diff = await db_snapshot.diff()
@@ -141,20 +172,18 @@ async def test_providers_data_structure_validation(
) -> None:
"""Test provider data structure contains expected fields"""
# Mock RIP-02 provider announcement event
# Mock NIP-91 provider announcement event
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"pubkey": "test_pubkey",
"created_at": 1234567890,
"content": "Comprehensive provider announcement",
"kind": 38421, # NIP-91 event kind
"content": '{"name": "Comprehensive Provider", "about": "A comprehensive AI provider"}',
"tags": [
["d", "provider-123"],
["endpoint", "https://api.provider.example/v1"],
["name", "Comprehensive Provider"],
["description", "A comprehensive AI provider"],
["model", "gpt-3.5-turbo"],
["model", "gpt-4"],
["u", "https://api.provider.example/v1"],
["models", "gpt-3.5-turbo", "gpt-4"],
],
}
]
@@ -190,19 +219,13 @@ async def test_providers_data_structure_validation(
assert "health" in provider_data
provider_info = provider_data["provider"]
# Expected fields from RIP-02 parser
expected_fields = ["id", "name", "endpoint_url", "supported_models"]
# health_info = provider_data["health"]
# Expected fields from NIP-91 parser (supported_models removed)
expected_fields = ["id", "name", "endpoint_url"]
for field in expected_fields:
assert field in provider_info
# Validate models structure if present
if "supported_models" in provider_info:
models = provider_info["supported_models"]
assert isinstance(models, list)
# Should have the models from the mocked event
assert "gpt-3.5-turbo" in models
assert "gpt-4" in models
@pytest.mark.integration
@pytest.mark.asyncio
@@ -211,8 +234,17 @@ async def test_providers_endpoint_no_providers_found(
) -> None:
"""Test providers endpoint when no providers are found"""
# Mock empty events (no providers mentioned)
mock_events: list[dict[str, Any]] = []
# Force empty discovery by returning events that are filtered out
mock_events: list[dict[str, Any]] = [
{
"id": "localhost-event",
"pubkey": "ignored_pubkey",
"kind": 38421,
"created_at": 1234567899,
"content": '{"name": "Local"}',
"tags": [["d", "local"], ["u", "http://localhost:8000"]],
}
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
@@ -239,23 +271,23 @@ async def test_providers_endpoint_offline_providers(
{
"id": "event1",
"pubkey": "healthy_provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": "Healthy provider announcement",
"content": '{"name": "Healthy Provider", "about": "Healthy provider announcement"}',
"tags": [
["d", "healthy-provider"],
["endpoint", "http://healthy-provider.onion"],
["name", "Healthy Provider"],
["u", "http://healthy-provider.onion"],
],
},
{
"id": "event2",
"pubkey": "offline_provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567891,
"content": "Offline provider announcement",
"content": '{"name": "Offline Provider", "about": "Offline provider announcement"}',
"tags": [
["d", "offline-provider"],
["endpoint", "http://offline-provider.onion"],
["name", "Offline Provider"],
["u", "http://offline-provider.onion"],
],
},
]
@@ -323,23 +355,23 @@ async def test_providers_endpoint_duplicate_urls(
{
"id": "event1",
"pubkey": "provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567890,
"content": "Provider announcement",
"content": '{"name": "Provider", "about": "Provider announcement"}',
"tags": [
["d", "provider-1"],
["endpoint", "http://provider.onion"],
["name", "Provider"],
["u", "http://provider.onion"],
],
},
{
"id": "event2",
"pubkey": "other_provider_pubkey",
"kind": 38421, # NIP-91 event kind
"created_at": 1234567892,
"content": "Different provider announcement",
"content": '{"name": "Other Provider", "about": "Different provider announcement"}',
"tags": [
["d", "other-provider"],
["endpoint", "http://other-provider.onion"],
["name", "Other Provider"],
["u", "http://other-provider.onion"],
],
},
]
@@ -432,11 +464,10 @@ async def test_providers_endpoint_malformed_urls(
assert response.status_code == 200
data = response.json()
# Should only extract valid onion URLs
providers = data["providers"]
for provider in providers:
assert provider.startswith("http://") or provider.startswith("https://")
assert provider.endswith(".onion")
# With NIP-91-only parsing, events without required tags are ignored
assert "providers" in data
assert isinstance(data["providers"], list)
assert len(data["providers"]) == 0
@pytest.mark.integration
@@ -575,8 +606,14 @@ async def test_providers_endpoint_parameter_validation(
mock_events: list[dict[str, Any]] = [
{
"id": "event1",
"content": "Provider: http://param-test-provider.onion",
"pubkey": "param_pubkey",
"kind": 38421,
"created_at": 1234567890,
"content": '{"name": "Param Test Provider"}',
"tags": [
["d", "param-test-provider"],
["u", "http://param-test-provider.onion"],
],
}
]
@@ -604,13 +641,14 @@ async def test_providers_endpoint_parameter_validation(
if len(providers) > 0:
if expected_json_format:
# Should be list of dictionaries
# Should be list of {provider, health} dictionaries
for item in providers:
assert isinstance(item, dict)
assert "provider" in item and "health" in item
else:
# Should be list of provider objects
for provider in providers:
assert isinstance(provider, dict)
else:
# Should be list of strings
for provider in providers:
assert isinstance(provider, str)
@pytest.mark.integration
Generated
+21 -1
View File
@@ -1361,6 +1361,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/73/d6b999782ae22f16971cc05378b3b33f6a89ede3b9619e8366aa23484bca/mypy_protobuf-3.6.0-py3-none-any.whl", hash = "sha256:56176e4d569070e7350ea620262478b49b7efceba4103d468448f1d21492fd6c", size = 16434 },
]
[[package]]
name = "nostr"
version = "0.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
{ name = "cryptography" },
{ name = "pycparser" },
{ name = "secp256k1" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/e1/1e24d8d2d75d28871f5b7d03304eda8250121ab665180872b9ba4ff70cc9/nostr-0.0.2.tar.gz", hash = "sha256:5c0c472f69764ae57870710d6b3bfe584df3ccdb0e2e3cd3f302f6b848124d24", size = 17189 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/ef/468ed56f0bea8e8979acf561273f8af2e7c9b3d8dc37bf80e81df08372a3/nostr-0.0.2-py3-none-any.whl", hash = "sha256:3d17d22dbd3aecf1ddf8cc72e330f14702e159fb0f43320d1ac88142db96aaba", size = 15397 },
]
[[package]]
name = "openai"
version = "1.98.0"
@@ -1767,7 +1783,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.1.1b"
version = "0.1.1b0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1777,9 +1793,11 @@ dependencies = [
{ name = "greenlet" },
{ name = "httpx", extra = ["socks"] },
{ name = "marshmallow" },
{ name = "nostr" },
{ name = "python-json-logger" },
{ name = "secp256k1" },
{ name = "sqlmodel" },
{ name = "websockets" },
]
[package.dev-dependencies]
@@ -1806,9 +1824,11 @@ requires-dist = [
{ name = "greenlet", specifier = ">=3.2.1" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.25.2" },
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
{ name = "nostr", specifier = ">=0.0.2" },
{ name = "python-json-logger", specifier = ">=2.0.0" },
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },
{ name = "sqlmodel", specifier = ">=0.0.24" },
{ name = "websockets", specifier = ">=12.0" },
]
[package.metadata.requires-dev]