ForcePilot/backend/package/yuxi/channels/adapters/nostr/profile.py
Kris a1f8288d20 feat(nostr): 实现完整的 Nostr 协议适配器模块
该提交新增了完整的 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 部署,内置安全防护与流量控制机制。
2026-05-12 00:47:41 +08:00

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", ""),
)