discovery wip

This commit is contained in:
Shroominic
2025-08-18 12:45:57 -03:00
parent 5e442d139c
commit 9ae0e4a238
2 changed files with 138 additions and 110 deletions
+15 -12
View File
@@ -49,7 +49,7 @@ async def query_nostr_relay_for_providers(
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=5)
message = await asyncio.wait_for(websocket.recv(), timeout=50)
data = json.loads(message)
if data[0] == "EVENT" and data[1] == sub_id:
@@ -87,7 +87,7 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
try:
tags = event.get("tags", [])
kind = event.get("kind")
# Common fields
d_tag = None
endpoint_urls = []
@@ -98,7 +98,7 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
supported_models = []
mint_url = None
version = None
# Parse NIP-91 format
if kind == 38421: # NIP-91 format
for tag in tags:
@@ -114,7 +114,7 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
mint_url = tag[1]
elif tag[0] == "version":
version = tag[1]
# Parse metadata from content for NIP-91
content = event.get("content", "")
if content:
@@ -127,13 +127,15 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
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']}")
print(
f"Invalid NIP-91 announcement - missing required fields: {event['id']}"
)
return None
else:
print(f"Unknown event kind: {kind}")
@@ -230,11 +232,12 @@ async def get_providers(
- NIP-91: https://github.com/nostr-protocol/nips/pull/1987
"""
# Default relays for provider discovery
discovery_relays = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://relay.routstr.com",
]
# discovery_relays = [
# "wss://relay.nostr.band",
# "wss://relay.damus.io",
# "wss://relay.routstr.com",
# ]
discovery_relays = os.getenv("RELAYS", "").split(",")
all_events = []
event_ids = set() # To avoid duplicates
+123 -98
View File
@@ -23,10 +23,10 @@ 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
"""
@@ -37,15 +37,17 @@ def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
# 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
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}")
@@ -63,7 +65,7 @@ def create_nip91_event(
) -> 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)
@@ -72,61 +74,63 @@ def create_nip91_event(
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
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)
# 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)])
# 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 = [
@@ -137,17 +141,17 @@ def create_nip91_event(
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,
@@ -158,7 +162,7 @@ def create_nip91_event(
"content": content,
"sig": signature_ser.hex(),
}
return event
@@ -169,37 +173,37 @@ async def query_nip91_events(
) -> 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']}")
@@ -209,19 +213,19 @@ async def query_nip91_events(
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
@@ -232,12 +236,12 @@ async def publish_to_relay(
) -> 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
"""
@@ -247,18 +251,23 @@ async def publish_to_relay(
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)
response = await asyncio.wait_for(websocket.recv(), timeout=50)
data = json.loads(response)
print(f"Response: {data}")
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 ''}")
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 ''}")
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]}")
@@ -266,11 +275,11 @@ async def publish_to_relay(
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
@@ -282,62 +291,67 @@ async def announce_provider() -> None:
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment
nsec = os.getenv("NOSTR_NSEC")
nsec = os.getenv("NSEC")
if not nsec:
logger.info("NOSTR_NSEC not found in environment, skipping NIP-91 announcement")
logger.info("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")
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}")
# Get configuration from environment
provider_id = os.getenv("ROUTSTR_PROVIDER_ID", os.getenv("HOSTNAME", "routstr-proxy"))
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")
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")
relay_urls_env = os.getenv("RELAYS")
print(f"RELAYS: {relay_urls_env}")
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://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:
@@ -346,14 +360,20 @@ async def announce_provider() -> None:
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: list[str] = 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):
existing_urls = [
tag[1] for tag in event.get("tags", []) if tag[0] == "u"
]
existing_models: list[str] = 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(
@@ -365,24 +385,29 @@ async def announce_provider() -> None:
version=os.getenv("ROUTSTR_VERSION", "0.1.0"),
metadata=metadata,
)
logger.info(f"Created NIP-91 announcement event: {event['id']}")
print(f"Created NIP-91 announcement event: {event}")
# 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")
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)))
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,
@@ -393,15 +418,15 @@ async def announce_provider() -> None:
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
# Continue running despite errors