from __future__ import annotations import asyncio import ipaddress import time from urllib.parse import urlparse from yuxi.channels.adapters.nostr.crypto import NostrCrypto from yuxi.channels.adapters.nostr.relay_manager import RelayManager from yuxi.channels.adapters.nostr.models import NostrProfile from yuxi.channels.adapters.nostr.state_store import NostrStateStore from yuxi.utils.logging_config import logger import json as _json def _validate_profile_url(url: str, field_name: str = "url") -> str | None: if not url: return None parsed = urlparse(url) if parsed.scheme != "https": return f"Profile {field_name} 仅允许 https 协议: {url}" hostname = parsed.hostname if not hostname: return f"Profile {field_name} 缺少 hostname: {url}" try: addr = ipaddress.ip_address(hostname) if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_multicast: return f"Profile {field_name} 禁止使用内网/回环地址: {hostname}" except ValueError: pass return None class NostrProfileManager: def __init__( self, crypto: NostrCrypto, relay_manager: RelayManager, state_store: NostrStateStore | None = None, ): self._crypto = crypto self._relay_manager = relay_manager self._state_store = state_store self._publish_lock = asyncio.Lock() self._last_publish_state: dict = {} async def publish_profile(self, profile: NostrProfile, account_id: str = "default") -> dict | None: picture_err = _validate_profile_url(profile.picture, "picture") banner_err = _validate_profile_url(profile.banner, "banner") website_err = _validate_profile_url(profile.website, "website") errors = [e for e in (picture_err, banner_err, website_err) if e] if errors: logger.warning("[NostrProfile] SSRF check failed: %s", "; ".join(errors)) raise ValueError("; ".join(errors)) async with self._publish_lock: return await self._do_publish(profile, account_id) async def _do_publish(self, profile: NostrProfile, account_id: str) -> dict | None: profile_data = { "name": profile.name or "", "display_name": profile.display_name or "", "about": profile.about or "", "picture": profile.picture or "", "banner": profile.banner or "", "website": profile.website or "", "nip05": profile.nip05 or "", "lud16": profile.lud16 or "", } content = {k: v for k, v in profile_data.items() if v} event = self._crypto.build_and_sign_event(kind=0, content=_json.dumps(content), tags=[]) success_count = await self._relay_manager.broadcast(event) if self._state_store: self._state_store.save_profile(account_id, content) self._last_publish_state = { "last_published_at": int(time.time()), "last_published_event_id": event.get("id") if success_count > 0 else None, "last_publish_results": {"success_count": success_count}, } logger.info( "[NostrProfile] Profile 已发布到 %s 个 Relay (account=%s)", success_count, account_id, ) return event if success_count > 0 else None @property def publish_state(self) -> dict: return self._last_publish_state async def import_profile_from_relays(self, pubkey: str, timeout: float = 15.0) -> NostrProfile | None: filters = [{"kinds": [0], "authors": [pubkey], "limit": 1}] try: events = await self._relay_manager.query(filters, timeout=timeout) except Exception: logger.debug("[NostrProfile] 查询 kind:0 失败: pubkey=%s", pubkey[:8], exc_info=True) return None if not events: return None best_event = max(events, key=lambda e: e.get("created_at", 0)) profile = self._parse_kind0_event(best_event) if profile: logger.info("[NostrProfile] 导入 Profile: pubkey=%s, name=%s", pubkey[:8], profile.name) return profile def merge_profiles(self, local: NostrProfile, imported: NostrProfile) -> NostrProfile: return NostrProfile( name=local.name or imported.name, display_name=local.display_name or imported.display_name, about=local.about or imported.about, picture=local.picture or imported.picture, banner=local.banner or imported.banner, website=local.website or imported.website, nip05=local.nip05 or imported.nip05, lud16=local.lud16 or imported.lud16, ) def load_local_profile(self, account_id: str = "default") -> NostrProfile | None: if not self._state_store: return None data = self._state_store.load_profile(account_id) if data: return NostrProfile(**data) return None async def publish_relay_list(self, account_id: str = "default") -> dict | None: relays = list(self._relay_manager._relay_urls) if hasattr(self._relay_manager, "_relay_urls") else [] if not relays: return None tags = [["r", url] for url in relays] event = self._crypto.build_and_sign_event(kind=10002, content="", tags=tags) success_count = await self._relay_manager.broadcast(event) logger.info( "[NostrProfile] Relay 列表已发布 kind:10002 到 %s 个 Relay", success_count, ) return event if success_count > 0 else None @staticmethod def _parse_kind0_event(event: dict) -> NostrProfile | None: content = event.get("content", "{}") try: metadata = _json.loads(content) except (_json.JSONDecodeError, TypeError): return None if not isinstance(metadata, dict): return None return NostrProfile( name=metadata.get("name", ""), display_name=metadata.get("display_name", ""), about=metadata.get("about", ""), picture=metadata.get("picture", ""), banner=metadata.get("banner", ""), website=metadata.get("website", ""), nip05=metadata.get("nip05", ""), lud16=metadata.get("lud16", ""), )