该提交新增了完整的 Nostr 去中心化社交网络适配器实现,包含以下核心功能: 1. 基础加密与密钥处理:支持 nsec/npub/hex 格式密钥转换,实现 NIP-04/NIP-17 加解密 2. Relay 管理与健康监控:支持多 Relay 连接、自动重连、健康评分与自动选优 3. 事件与消息处理:实现事件校验、去重、速率限制、消息缓存与状态持久化 4. 个人资料管理:支持发布/导入/合并 Nostr Kind 0 元数据事件 5. 配对机制:实现安全的双向配对通信流程 6. Zap 功能:支持 NIP-57 打赏请求与收据解析 7. NIP-05 验证:实现域名身份验证 8. 配置系统:完整的配置校验与多账户支持 9. 监控与指标:提供连接监控、事件统计与健康快照 10. HTTP API:提供个人资料管理的 RESTful 接口 11. 安装向导:命令行配置向导与初始化流程 所有模块均遵循 Nostr 协议规范,支持多账户、多 Relay 部署,内置安全防护与流量控制机制。
164 lines
6.3 KiB
Python
164 lines
6.3 KiB
Python
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", ""),
|
|
)
|