diff --git a/routstr/discovery.py b/routstr/discovery.py index 0a159ab4..0eb245d8 100644 --- a/routstr/discovery.py +++ b/routstr/discovery.py @@ -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 diff --git a/routstr/nip91.py b/routstr/nip91.py index 298e1a53..6c0b8115 100644 --- a/routstr/nip91.py +++ b/routstr/nip91.py @@ -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 \ No newline at end of file + # Continue running despite errors