mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
discovery wip
This commit is contained in:
+15
-12
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user