mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-11 11:47:50 +00:00
Add NIP-91 provider discovery and auto-announcement support
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
This commit is contained in:
co-authored by
db2002dominic
parent
f2890d819e
commit
dff80b9e5d
@@ -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 both RIP-02 and NIP-91 protocols
|
||||
- **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,7 @@ 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`
|
||||
@@ -97,6 +99,18 @@ The most common settings are shown below. See `.env.example` for the full list.
|
||||
- `HTTP_URL` – Public-facing URL of the proxy
|
||||
- `ONION_URL` – Tor hidden service URL of the proxy
|
||||
|
||||
### NIP-91 Provider Announcement Settings
|
||||
- `NOSTR_NSEC` – Nostr private key in hex format for announcing this provider (optional)
|
||||
- `ROUTSTR_PROVIDER_ID` – Unique identifier for this provider instance (defaults to hostname)
|
||||
- `ROUTSTR_BASE_URL` – Base URL for the provider API (defaults to http://localhost:8000)
|
||||
- `ROUTSTR_ONION_URL` – Tor hidden service URL for the provider (optional)
|
||||
- `ROUTSTR_MINT_URL` – Associated ecash mint URL for payments (optional)
|
||||
- `ROUTSTR_PROVIDER_NAME` – Human-readable name for the provider (defaults to "Routstr Proxy")
|
||||
- `ROUTSTR_PROVIDER_ABOUT` – Description of the provider (defaults to "Privacy-preserving AI proxy via Nostr")
|
||||
- `ROUTSTR_VERSION` – Provider software version (defaults to "0.1.0")
|
||||
- `NOSTR_RELAYS` – Comma-separated list of Nostr relay URLs for announcing (defaults to popular relays)
|
||||
- `NIP91_ANNOUNCEMENT_INTERVAL` – Seconds between re-announcements (defaults to 86400/24 hours)
|
||||
|
||||
## Database Migrations
|
||||
|
||||
The application uses Alembic for database schema management and **automatically runs migrations on startup**. This ensures your database is always up-to-date when deploying new versions.
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"cashu",
|
||||
"secp256k1",
|
||||
"marshmallow>=3.13,<4.0",
|
||||
"websockets>=12.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import 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
|
||||
@@ -28,6 +29,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
@@ -42,6 +44,7 @@ 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())
|
||||
|
||||
yield
|
||||
|
||||
@@ -58,6 +61,8 @@ 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()
|
||||
|
||||
try:
|
||||
tasks_to_wait = []
|
||||
@@ -65,6 +70,8 @@ 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 tasks_to_wait:
|
||||
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
|
||||
|
||||
+91
-43
@@ -24,14 +24,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 both RIP-02 (kind:31338) and NIP-91 (kind:38421) events.
|
||||
"""
|
||||
events = []
|
||||
|
||||
# Build filter according to RIP-02 spec
|
||||
# Build filter for both RIP-02 and NIP-91 events
|
||||
filter_obj: dict[str, Any] = {
|
||||
"kinds": [31338], # RIP-02 Provider Announcement events
|
||||
"kinds": [31338, 38421], # Both RIP-02 and NIP-91 Provider Announcements
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ async def query_nostr_relay_for_providers(
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, timeout=timeout) as websocket:
|
||||
print("Connected to relay, searching for kind 31338 events")
|
||||
print("Connected to relay, searching for provider announcement events")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
@@ -80,61 +80,105 @@ 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 both RIP-02 (kind:31338) and NIP-91 (kind:38421) formats.
|
||||
Returns structured provider data or None if invalid.
|
||||
"""
|
||||
try:
|
||||
# Extract required tags according to RIP-02
|
||||
tags = event.get("tags", [])
|
||||
|
||||
# Find required tags
|
||||
endpoint_url = None
|
||||
provider_name = None
|
||||
kind = event.get("kind")
|
||||
|
||||
# Common fields
|
||||
d_tag = None
|
||||
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
if tag[0] == "endpoint":
|
||||
endpoint_url = tag[1]
|
||||
elif tag[0] == "name":
|
||||
provider_name = tag[1]
|
||||
elif tag[0] == "d":
|
||||
d_tag = tag[1]
|
||||
|
||||
# Validate required fields
|
||||
if not endpoint_url or not provider_name or not d_tag:
|
||||
print(
|
||||
f"Invalid provider announcement - missing required tags: {event['id']}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract optional tags
|
||||
endpoint_urls = []
|
||||
provider_name = None
|
||||
description = None
|
||||
contact = None
|
||||
pricing_url = None
|
||||
supported_models = []
|
||||
|
||||
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])
|
||||
mint_url = None
|
||||
version = None
|
||||
|
||||
# Parse based on event kind
|
||||
if kind == 31338: # RIP-02 format
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
if tag[0] == "endpoint":
|
||||
endpoint_urls.append(tag[1])
|
||||
elif tag[0] == "name":
|
||||
provider_name = tag[1]
|
||||
elif tag[0] == "d":
|
||||
d_tag = tag[1]
|
||||
elif 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])
|
||||
|
||||
# RIP-02 requires single endpoint
|
||||
endpoint_url = endpoint_urls[0] if endpoint_urls else None
|
||||
|
||||
# Validate RIP-02 required fields
|
||||
if not endpoint_url or not provider_name or not d_tag:
|
||||
print(f"Invalid RIP-02 announcement - missing required tags: {event['id']}")
|
||||
return None
|
||||
|
||||
elif 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] == "models" and len(tag) > 1:
|
||||
# NIP-91 uses single models tag with multiple values
|
||||
supported_models = tag[1:]
|
||||
elif tag[0] == "mint":
|
||||
mint_url = 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")
|
||||
contact = metadata.get("contact")
|
||||
except:
|
||||
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:
|
||||
print(f"Invalid NIP-91 announcement - missing required fields: {event['id']}")
|
||||
return None
|
||||
else:
|
||||
print(f"Unknown event kind: {kind}")
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": event["id"],
|
||||
"pubkey": event["pubkey"],
|
||||
"created_at": event["created_at"],
|
||||
"kind": kind,
|
||||
"d_tag": d_tag,
|
||||
"endpoint_url": endpoint_url,
|
||||
"endpoint_urls": endpoint_urls, # All URLs for NIP-91
|
||||
"name": provider_name,
|
||||
"description": description,
|
||||
"contact": contact,
|
||||
"pricing_url": pricing_url,
|
||||
"mint_url": mint_url,
|
||||
"version": version,
|
||||
"supported_models": supported_models,
|
||||
"content": event.get("content", ""),
|
||||
}
|
||||
@@ -204,10 +248,14 @@ 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.
|
||||
Discover Routstr providers using both RIP-02 and NIP-91 specifications.
|
||||
Searches for provider announcement events on Nostr relays:
|
||||
- kind:31338 (RIP-02)
|
||||
- kind:38421 (NIP-91)
|
||||
|
||||
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
|
||||
References:
|
||||
- RIP-02: https://github.com/Routstr/protocol/blob/main/RIP-02.md
|
||||
- NIP-91: https://github.com/nostr-protocol/nips/pull/1987
|
||||
"""
|
||||
# Default relays for provider discovery
|
||||
discovery_relays = [
|
||||
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NIP-91: Routstr Provider Discoverability Implementation
|
||||
Automatically announces this Routstr proxy instance to Nostr relays.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import secp256k1
|
||||
import websockets
|
||||
|
||||
from .core import get_logger
|
||||
from .payment.models import MODELS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
# Handle nsec format
|
||||
if nsec.startswith("nsec"):
|
||||
# Simple bech32 decode - for production use a proper library
|
||||
# For now, we'll assume hex format is passed
|
||||
logger.warning("nsec format not yet implemented, please use hex format")
|
||||
return None
|
||||
|
||||
# Assume hex format
|
||||
if len(nsec) != 64:
|
||||
logger.error(f"Invalid private key length: {len(nsec)}")
|
||||
return None
|
||||
|
||||
private_key = secp256k1.PrivateKey(bytes.fromhex(nsec))
|
||||
public_key = private_key.pubkey.serialize(compressed=True)[1:] # Remove 0x02/0x03 prefix
|
||||
|
||||
return (nsec, public_key.hex())
|
||||
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],
|
||||
supported_models: list[str],
|
||||
mint_url: str | None = None,
|
||||
version: str = "0.1.0",
|
||||
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
|
||||
supported_models: List of supported AI model IDs
|
||||
mint_url: Optional ecash mint URL for payments
|
||||
version: Provider software version
|
||||
metadata: Optional metadata dictionary (name, picture, about, etc.)
|
||||
|
||||
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 tags according to NIP-91
|
||||
tags = [
|
||||
["d", provider_id], # Unique identifier
|
||||
]
|
||||
|
||||
# Add URLs
|
||||
for url in endpoint_urls:
|
||||
tags.append(["u", url])
|
||||
|
||||
# Add models as a single tag with multiple values
|
||||
if supported_models:
|
||||
tags.append(["models"] + supported_models)
|
||||
|
||||
# Add optional tags
|
||||
if mint_url:
|
||||
tags.append(["mint", mint_url])
|
||||
tags.append(["version", version])
|
||||
|
||||
# Add model capabilities if detailed info available
|
||||
for model in MODELS:
|
||||
if model.id in supported_models:
|
||||
capabilities = []
|
||||
|
||||
# Add max_tokens from context_length
|
||||
if model.context_length:
|
||||
capabilities.append(f"max_tokens:{model.context_length}")
|
||||
|
||||
# Check if model supports vision (simplified check)
|
||||
if any(modal in ["image"] for modal in model.architecture.input_modalities):
|
||||
capabilities.append("vision:true")
|
||||
else:
|
||||
capabilities.append("vision:false")
|
||||
|
||||
# Check if model supports tools (simplified - most modern models do)
|
||||
if "gpt" in model.id or "claude" in model.id or "llama" in model.id:
|
||||
capabilities.append("tools:true")
|
||||
else:
|
||||
capabilities.append("tools:false")
|
||||
|
||||
if capabilities:
|
||||
tags.append(["model-cap", model.id, ",".join(capabilities)])
|
||||
|
||||
# Content is optional metadata as JSON string
|
||||
content = ""
|
||||
if metadata:
|
||||
content = json.dumps(metadata, separators=(",", ":"))
|
||||
|
||||
# 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
|
||||
38421, # Kind for NIP-91 Provider Announcements
|
||||
tags, # Tags array
|
||||
content, # Content (metadata)
|
||||
]
|
||||
|
||||
# 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_ser = private_key.ecdsa_serialize(signature)
|
||||
|
||||
# Create the final event
|
||||
event = {
|
||||
"id": event_id,
|
||||
"pubkey": public_key.hex(),
|
||||
"created_at": created_at,
|
||||
"kind": 38421,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": signature_ser.hex(),
|
||||
}
|
||||
|
||||
return event
|
||||
|
||||
|
||||
async def query_nip91_events(
|
||||
relay_url: str,
|
||||
pubkey: str,
|
||||
timeout: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query a Nostr relay for NIP-91 provider announcements (kind:38421).
|
||||
|
||||
Args:
|
||||
relay_url: WebSocket URL of the nostr relay
|
||||
pubkey: Public key to filter by
|
||||
timeout: Connection timeout in seconds
|
||||
|
||||
Returns:
|
||||
List of NIP-91 events from the given pubkey
|
||||
"""
|
||||
events = []
|
||||
|
||||
# Build filter for NIP-91 events from specific pubkey
|
||||
filter_obj = {
|
||||
"kinds": [38421],
|
||||
"authors": [pubkey],
|
||||
"limit": 10,
|
||||
}
|
||||
|
||||
sub_id = f"nip91_{int(time.time())}"
|
||||
req_message = json.dumps(["REQ", sub_id, filter_obj])
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, timeout=timeout) as websocket:
|
||||
logger.info(f"Querying {relay_url} for existing NIP-91 events")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
data = json.loads(message)
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
event = data[2]
|
||||
logger.info(f"Found existing NIP-91 event: {event['id']}")
|
||||
events.append(event)
|
||||
elif data[0] == "EOSE" and data[1] == sub_id:
|
||||
logger.debug("Received EOSE message")
|
||||
break
|
||||
elif data[0] == "NOTICE":
|
||||
logger.warning(f"Relay notice: {data[1]}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Timeout waiting for relay response")
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Failed to decode relay message as JSON")
|
||||
continue
|
||||
|
||||
await websocket.send(json.dumps(["CLOSE", sub_id]))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to query relay {relay_url}: {e}")
|
||||
|
||||
return events
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
relay_url: WebSocket URL of the nostr relay
|
||||
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)
|
||||
logger.info(f"Sent NIP-91 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
|
||||
logger.info(f"Event accepted by {relay_url}: {data[3] if len(data) > 3 else ''}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Event rejected by {relay_url}: {data[3] if len(data) > 3 else ''}")
|
||||
return False
|
||||
elif data[0] == "NOTICE":
|
||||
logger.warning(f"Relay notice from {relay_url}: {data[1]}")
|
||||
return False
|
||||
else:
|
||||
logger.warning(f"Unexpected response from {relay_url}: {data}")
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"No response from {relay_url} within timeout")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to publish to {relay_url}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
nsec = os.getenv("NOSTR_NSEC")
|
||||
if not nsec:
|
||||
logger.info("NOSTR_NSEC not found in environment, skipping NIP-91 announcement")
|
||||
return
|
||||
|
||||
# Convert NSEC to keypair
|
||||
keypair = nsec_to_keypair(nsec)
|
||||
if not keypair:
|
||||
logger.error("Failed to parse NOSTR_NSEC, skipping NIP-91 announcement")
|
||||
return
|
||||
|
||||
private_key_hex, public_key_hex = keypair
|
||||
logger.info(f"Using Nostr pubkey: {public_key_hex}")
|
||||
|
||||
# Get configuration from environment
|
||||
provider_id = os.getenv("ROUTSTR_PROVIDER_ID", os.getenv("HOSTNAME", "routstr-proxy"))
|
||||
base_url = os.getenv("ROUTSTR_BASE_URL", "http://localhost:8000")
|
||||
onion_url = os.getenv("ROUTSTR_ONION_URL")
|
||||
mint_url = os.getenv("ROUTSTR_MINT_URL")
|
||||
provider_name = os.getenv("ROUTSTR_PROVIDER_NAME", "Routstr Proxy")
|
||||
provider_about = os.getenv("ROUTSTR_PROVIDER_ABOUT", "Privacy-preserving AI proxy via Nostr")
|
||||
|
||||
# Build endpoint URLs
|
||||
endpoint_urls = [base_url]
|
||||
if onion_url:
|
||||
endpoint_urls.append(onion_url)
|
||||
|
||||
# Get supported models
|
||||
supported_models = [model.id for model in MODELS]
|
||||
if not supported_models:
|
||||
logger.warning("No models loaded, will announce with empty model list")
|
||||
supported_models = []
|
||||
|
||||
# Build metadata
|
||||
metadata = {
|
||||
"name": provider_name,
|
||||
"about": provider_about,
|
||||
}
|
||||
|
||||
# Get relay URLs from environment or use defaults
|
||||
relay_urls_env = os.getenv("NOSTR_RELAYS")
|
||||
if relay_urls_env:
|
||||
relay_urls = [url.strip() for url in relay_urls_env.split(",")]
|
||||
else:
|
||||
relay_urls = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
|
||||
# Check for existing announcements
|
||||
existing_events = []
|
||||
for relay_url in relay_urls:
|
||||
events = await query_nip91_events(relay_url, public_key_hex)
|
||||
existing_events.extend(events)
|
||||
|
||||
# Check if we need to publish (no events or outdated)
|
||||
should_publish = True
|
||||
if existing_events:
|
||||
# Check if any existing event matches our current configuration
|
||||
for event in existing_events:
|
||||
tags_dict = {tag[0]: tag[1:] for tag in event.get("tags", [])}
|
||||
if tags_dict.get("d", [""])[0] == provider_id:
|
||||
# Check if configuration has changed
|
||||
existing_urls = [tag[1] for tag in event.get("tags", []) if tag[0] == "u"]
|
||||
existing_models = next((tag[1:] for tag in event.get("tags", []) if tag[0] == "models"), [])
|
||||
|
||||
if set(existing_urls) == set(endpoint_urls) and set(existing_models) == set(supported_models):
|
||||
logger.info("Existing NIP-91 announcement is up to date")
|
||||
should_publish = False
|
||||
break
|
||||
|
||||
if should_publish:
|
||||
# Create new NIP-91 event
|
||||
event = create_nip91_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
supported_models=supported_models,
|
||||
mint_url=mint_url,
|
||||
version=os.getenv("ROUTSTR_VERSION", "0.1.0"),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(f"Created NIP-91 announcement event: {event['id']}")
|
||||
|
||||
# Publish to all relays
|
||||
success_count = 0
|
||||
for relay_url in relay_urls:
|
||||
if await publish_to_relay(relay_url, event):
|
||||
success_count += 1
|
||||
|
||||
logger.info(f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays")
|
||||
|
||||
# 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)
|
||||
|
||||
# Re-create and publish event
|
||||
event = create_nip91_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_id=provider_id,
|
||||
endpoint_urls=endpoint_urls,
|
||||
supported_models=[model.id for model in MODELS], # Refresh model list
|
||||
mint_url=mint_url,
|
||||
version=os.getenv("ROUTSTR_VERSION", "0.1.0"),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
logger.info(f"Re-announcing provider (periodic update): {event['id']}")
|
||||
|
||||
for relay_url in relay_urls:
|
||||
await publish_to_relay(relay_url, event)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("NIP-91 announcement task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in NIP-91 announcement loop: {e}")
|
||||
# Continue running despite errors
|
||||
Reference in New Issue
Block a user