ForcePilot/backend/package/yuxi/channels/adapters/nostr/adapter.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

951 lines
38 KiB
Python

from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Any
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelException,
)
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
ChatType,
DeliveryResult,
EventType,
HealthStatus,
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.channels.adapters.nostr.config import NostrConfig
from yuxi.channels.adapters.nostr.crypto import NostrCrypto, NostrCryptoError
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
from yuxi.channels.adapters.nostr.send import NostrSender
from yuxi.channels.adapters.nostr.monitor import NostrMonitor
from yuxi.channels.adapters.nostr.probe import probe_relay
from yuxi.channels.adapters.nostr.guard import NostrGuard, GuardPolicy
from yuxi.channels.adapters.nostr.health import RelayHealthTracker
from yuxi.channels.adapters.nostr.crypto import normalize_pubkey
from yuxi.channels.adapters.nostr.state_store import NostrStateStore
from yuxi.channels.adapters.nostr.metrics import NostrMetrics
from yuxi.channels.adapters.nostr.send_cache import SendCache
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
import aiohttp
import json as _json
@dataclass
class _AccountComponents:
crypto: NostrCrypto | None = None
relay_manager: RelayManager | None = None
sender: NostrSender | None = None
monitor: NostrMonitor | None = None
guard: NostrGuard | None = None
state_store: NostrStateStore | None = None
send_cache: SendCache | None = None
metrics: NostrMetrics | None = None
config: NostrConfig | None = None
@register_builtin_adapter
class NostrAdapter(BaseChannelAdapter):
channel_id = "nostr"
channel_type = ChannelType.NOSTR
supports_markdown = False
supports_streaming = True
streaming_modes = ["off", "block", "progress"]
max_media_size_mb = 10
channel_env_vars = ["NOSTR_PRIVATE_KEY"]
capabilities = ChannelCapabilities(
chat_types=["direct"],
reactions=True,
edit=True,
unsend=True,
reply=True,
media=False,
supports_markdown=False,
supports_streaming=True,
streaming_modes=["off", "block", "progress"],
text_chunk_limit=4096,
max_media_size_mb=10,
)
meta = ChannelMeta(
id="nostr",
label="Nostr",
selection_label="Nostr (Decentralized)",
blurb=(
"Decentralized social network DM protocol. "
"Supports NIP-04/NIP-17 encryption, message editing, reactions, and streaming."
),
docs_path="/docs/channels/nostr",
docs_label="Nostr Setup Guide",
order=60,
system_image="nostr.svg",
)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._nostr_config: NostrConfig | None = None
self._crypto: NostrCrypto | None = None
self._relay_manager: RelayManager | None = None
self._sender: NostrSender | None = None
self._monitor: NostrMonitor | None = None
self._guard: NostrGuard | None = None
self._health_tracker = RelayHealthTracker()
self._state_store: NostrStateStore | None = None
self._metrics = NostrMetrics()
self._send_cache = SendCache()
self._active_buses: dict[str, Any] = {}
self._pending_tasks: list[asyncio.Task] = []
self._state_dir = self._resolve_state_dir()
self._order_buffer: list[dict] = []
self._order_lock = asyncio.Lock()
self._ordering_task: asyncio.Task | None = None
self._account_managers: dict[str, _AccountComponents] = {}
self._active_account_id: str = "default"
async def connect(self) -> None:
self._status = ChannelStatus.CONNECTING
logger.info(f"[Nostr] Starting channel '{self.config.get('name', self.channel_id)}'")
self._nostr_config = NostrConfig.from_dict(self.config)
await self._init_primary_account(self._nostr_config)
if self._nostr_config.multi_account_enabled:
account_configs = self._nostr_config.get_account_configs(self.config)
for account_id, account_cfg in account_configs.items():
if account_id == "default":
continue
logger.info(f"[Nostr] 初始化额外账户: {account_id}")
try:
await self._init_additional_account(account_id, account_cfg)
except ChannelAuthenticationError as e:
logger.warning(f"[Nostr] 账户 '{account_id}' 初始化失败: {e}")
except Exception as e:
logger.warning(f"[Nostr] 账户 '{account_id}' 初始化异常: {e}")
self._relay_manager.on_connect(lambda url: self._on_relay_connected_metrics(url))
self._relay_manager.on_disconnect(lambda url: self._on_relay_disconnected_metrics(url))
await self._relay_manager.connect_all()
for components in self._account_managers.values():
if components.relay_manager is not self._relay_manager:
await components.relay_manager.connect_all()
if self._nostr_config.nip42_auth_enabled:
await self._perform_nip42_auth()
await self._monitor.subscribe(self._nostr_config.dm_policy)
self._monitor.on_event(self._on_relay_event)
await self._monitor.start()
for components in self._account_managers.values():
if components.monitor is not self._monitor:
await components.monitor.subscribe(components.config.dm_policy)
components.monitor.on_event(self._on_relay_event)
await components.monitor.start()
if self._nostr_config.message_ordering:
self._ordering_task = asyncio.create_task(self._run_order_buffer())
self._status = ChannelStatus.CONNECTED
account_count = 1 + len(self._account_managers)
logger.info(f"[Nostr] Channel connected. npub: {self._crypto.npub}, 账户数: {account_count}")
async def _init_primary_account(self, cfg: NostrConfig) -> None:
try:
self._crypto = NostrCrypto(cfg.private_key)
except NostrCryptoError:
raise ChannelAuthenticationError(
"Nostr 私钥无效,请在配置中提供有效的 private_key (nsec/hex) 或留空自动生成"
)
self._state_store = NostrStateStore(self._state_dir, channel_name="nostr")
self._send_cache = SendCache(max_size=cfg.send_message_cache_size)
cached_entries = self._state_store.load_send_cache()
if cached_entries:
self._send_cache = SendCache.from_dict_list(
cached_entries, max_size=cfg.send_message_cache_size
)
self._relay_manager = RelayManager(
cfg.relays,
timeout=cfg.relay_timeout_sec,
reconnect_interval=cfg.reconnect_interval_sec,
)
self._sender = NostrSender(self._crypto, self._relay_manager, cfg)
self._monitor = NostrMonitor(self._crypto, self._relay_manager, backfill_window_sec=cfg.backfill_window_sec)
guard_policy = self._build_guard_policy(cfg)
self._guard = NostrGuard(self._crypto.pubkey_hex(), guard_policy)
self._restore_state()
async def _init_additional_account(self, account_id: str, cfg: NostrConfig) -> None:
try:
crypto = NostrCrypto(cfg.private_key)
except NostrCryptoError:
raise ChannelAuthenticationError(
f"账户 '{account_id}' 私钥无效"
)
state_store = NostrStateStore(self._state_dir, channel_name=f"nostr_{account_id}")
send_cache = SendCache(max_size=cfg.send_message_cache_size)
relay_manager = RelayManager(
cfg.relays,
timeout=cfg.relay_timeout_sec,
reconnect_interval=cfg.reconnect_interval_sec,
)
sender = NostrSender(crypto, relay_manager, cfg)
monitor = NostrMonitor(crypto, relay_manager, backfill_window_sec=cfg.backfill_window_sec)
guard_policy = self._build_guard_policy(cfg)
guard = NostrGuard(crypto.pubkey_hex(), guard_policy)
cached = state_store.load_send_cache()
if cached:
send_cache = SendCache.from_dict_list(cached, max_size=cfg.send_message_cache_size)
relay_manager.on_connect(lambda url: self._on_relay_connected_metrics(url))
relay_manager.on_disconnect(lambda url: self._on_relay_disconnected_metrics(url))
self._account_managers[account_id] = _AccountComponents(
crypto=crypto,
relay_manager=relay_manager,
sender=sender,
monitor=monitor,
guard=guard,
state_store=state_store,
send_cache=send_cache,
metrics=NostrMetrics(),
config=cfg,
)
@staticmethod
def _build_guard_policy(cfg: NostrConfig) -> GuardPolicy:
return GuardPolicy(
allowed_kinds=set(cfg.guard_policy.allowed_kinds),
max_ciphertext_bytes=cfg.guard_policy.max_ciphertext_bytes,
max_plaintext_bytes=cfg.guard_policy.max_plaintext_bytes,
max_future_skew_sec=cfg.guard_policy.max_future_skew_sec,
rate_limit_window_ms=cfg.guard_policy.rate_limit.window_ms,
rate_limit_max_per_sender_per_window=cfg.guard_policy.rate_limit.max_per_sender_per_window,
rate_limit_max_global_per_window=cfg.guard_policy.rate_limit.max_global_per_window,
dm_policy=cfg.dm_policy,
allow_from=cfg.allow_from,
)
def _restore_state(self) -> None:
saved_events = self._state_store.load_seen_events()
for event_id in saved_events:
self._guard._seen.mark_seen(event_id)
saved_inflight = self._state_store.load_inflight()
for event_id in saved_inflight:
self._guard._inflight.add(event_id)
if saved_events or saved_inflight:
logger.info(
"[Nostr] 从 state_store 恢复状态: seen=%s, inflight=%s",
len(saved_events),
len(saved_inflight),
)
last_ts = self._state_store.load_last_processed_at()
if last_ts and self._monitor._last_subscribe_ts == 0:
self._monitor._last_subscribe_ts = last_ts
async def _perform_nip42_auth(self) -> None:
auth_event = self._crypto.build_and_sign_event(
kind=22242,
content="",
tags=[["relay", url] for url in (self._nostr_config.nip42_auth_urls or self._nostr_config.relays)],
)
auth_urls = self._nostr_config.nip42_auth_urls or self._nostr_config.relays
auth_results: dict[str, bool] = {url: False for url in auth_urls}
max_retries = 3
base_delay = 2.0
for attempt in range(max_retries):
remaining = [url for url, ok in auth_results.items() if not ok]
if not remaining:
break
result = await self._relay_manager.send_auth(auth_event, urls=remaining)
for url, ok in result.items():
if ok:
auth_results[url] = True
logger.info(f"[Nostr] NIP-42 AUTH 成功: {url}")
else:
logger.warning(f"[Nostr] NIP-42 AUTH 失败: {url} (尝试 {attempt + 1}/{max_retries})")
if remaining:
delay = base_delay * (2**attempt)
await asyncio.sleep(delay)
failed = [url for url, ok in auth_results.items() if not ok]
if failed:
logger.warning(f"[Nostr] NIP-42 AUTH 最终失败 {len(failed)} 个 Relay: {failed}")
async def disconnect(self) -> None:
if self._ordering_task:
self._ordering_task.cancel()
self._ordering_task = None
if self._state_store:
self._state_store.save_last_processed_at(self._monitor._last_subscribe_ts if self._monitor else 0)
self._state_store.save_send_cache(self._send_cache.to_dict_list())
if self._guard:
self._state_store.save_inflight(list(self._guard.inflight))
tasks = []
if self._monitor:
tasks.append(self._monitor.stop())
if self._relay_manager:
tasks.append(self._relay_manager.disconnect_all())
if tasks:
await asyncio.gather(*tasks)
self._status = ChannelStatus.DISCONNECTED
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender 未初始化")
try:
result = await self._sender.send(response)
if result.success and result.message_id:
self._send_cache.record(result.message_id, response.content[:100], "sent")
return result
except NostrCryptoError as e:
return DeliveryResult(success=False, error=f"加密错误: {e}")
except Exception as e:
logger.warning(f"[Nostr] 消息发送异常: {e}")
return DeliveryResult(success=False, error=str(e))
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender 未初始化")
receiver_pubkey = ""
if ":" in chat_id:
receiver_pubkey = chat_id.split(":")[-1]
url = data if isinstance(data, str) else ""
caption = f"[{media_type}]"
try:
return await self._sender.send_media_text(
media_url=url,
caption=caption,
receiver_pubkey=receiver_pubkey,
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender 未初始化")
receiver_pubkey = ""
if ":" in chat_id:
receiver_pubkey = chat_id.split(":")[-1]
try:
return await self._sender.send_edit(
original_event_id=msg_id,
new_content=content,
receiver_pubkey=receiver_pubkey,
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender 未初始化")
try:
return await self._sender.send_delete(original_event_id=msg_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender 未初始化")
target_pubkey = msg_id
if ":" in msg_id:
parts = msg_id.split(":")
target_pubkey = parts[-1]
return await self._sender.send_reaction(chat_id, target_pubkey, emoji)
async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender 未初始化")
target_pubkey = msg_id
if ":" in msg_id:
parts = msg_id.split(":")
target_pubkey = parts[-1]
return await self._sender.remove_reaction(msg_id, target_pubkey)
async def send_chat_action(self, chat_id: str, action: str) -> DeliveryResult:
if not self._sender:
return DeliveryResult(success=False, error="Sender 未初始化")
receiver_pubkey = ""
if ":" in chat_id:
receiver_pubkey = chat_id.split(":")[-1]
return await self._sender.send_typing(chat_id, receiver_pubkey)
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
receiver_pubkey = ""
if ":" in chat_id:
receiver_pubkey = chat_id.split(":")[-1]
if finished:
if msg_id and self._sender:
return await self._sender.send_edit(
original_event_id=msg_id,
new_content=chunk,
receiver_pubkey=receiver_pubkey,
)
identity = self._build_stream_identity(chat_id, msg_id)
response = ChannelResponse(identity=identity, content=chunk)
return await self.send(response)
if msg_id and self._sender:
return await self._sender.send_edit(
original_event_id=msg_id,
new_content=chunk,
receiver_pubkey=receiver_pubkey,
)
identity = self._build_stream_identity(chat_id, msg_id)
response = ChannelResponse(identity=identity, content=chunk)
return await self.send(response)
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._relay_manager:
return {}
filters = [{"kinds": [0], "authors": [channel_user_id], "limit": 1}]
try:
events = await self._relay_manager.query(filters, timeout=10)
for event in events:
content = event.get("content", "{}")
metadata = _json.loads(content)
return {
"pubkey": channel_user_id,
"name": metadata.get("name", ""),
"display_name": metadata.get("display_name", ""),
"picture": metadata.get("picture", ""),
"banner": metadata.get("banner", ""),
"website": metadata.get("website", ""),
"about": metadata.get("about", ""),
"nip05": metadata.get("nip05", ""),
}
except Exception:
logger.debug(f"[Nostr] 获取用户信息失败: {channel_user_id}", exc_info=True)
return {"pubkey": channel_user_id}
async def download_media(self, file_id: str) -> bytes:
try:
async with aiohttp.ClientSession() as session:
async with session.get(file_id, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status == 200:
content_length = resp.content_length or 0
if content_length > self.max_media_size_mb * 1024 * 1024:
raise ChannelException(
f"媒体文件超过大小限制 ({self.max_media_size_mb}MB)",
retryable=False,
)
return await resp.read()
raise ChannelException(
f"媒体下载失败 HTTP {resp.status}",
retryable=True,
)
except ChannelException:
raise
except Exception as e:
raise ChannelException(f"媒体下载出错: {e}", retryable=True)
async def pre_connect(self) -> dict:
private_key = self.config.get("private_key") or self.config.get("accounts", {}).get("default", {}).get(
"private_key", ""
)
relays = self.config.get("relays") or self.config.get("accounts", {}).get("default", {}).get("relays")
if not relays:
relays = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.info",
"wss://nos.lol",
]
if not private_key:
return {"status": "error", "message": "Missing private_key (nsec/hex)"}
try:
crypto = NostrCrypto(private_key)
pubkey_ok = crypto.npub
except NostrCryptoError as e:
return {"status": "error", "message": str(e)}
probe_tasks = [probe_relay(url, timeout=8.0) for url in relays]
probe_results = await asyncio.gather(*probe_tasks, return_exceptions=True)
reachable = 0
probe_details: list[dict] = []
for result in probe_results:
if isinstance(result, Exception):
probe_details.append({"error": str(result)})
continue
entry = {
"url": result.url,
"connected": result.connected,
"latency_ms": round(result.latency_ms, 1) if result.latency_ms else None,
}
if result.connected:
reachable += 1
if result.error:
entry["error"] = result.error[:120]
probe_details.append(entry)
if reachable == 0:
return {
"status": "warning",
"message": "所有 Relay 探测不可达",
"npub": pubkey_ok,
"relay_probe": probe_details,
}
return {
"status": "ok",
"npub": pubkey_ok,
"relays_reachable": f"{reachable}/{len(relays)}",
"relay_probe": probe_details,
}
def normalize_inbound(self, raw: dict) -> ChannelMessage:
kind = raw.get("kind", 1)
content = raw.get("content", "")
pubkey = raw.get("pubkey", "")
event_id = raw.get("id", "")
tags: list[list[str]] = raw.get("tags", [])
event_type = EventType.MESSAGE_RECEIVED
if kind == 5:
event_type = EventType.MESSAGE_DELETED
e_tags = [t for t in tags if t and t[0] == "e" and len(t) > 1]
deleted_ids = [t[1] for t in e_tags]
content = f"(deleted events: {', '.join(deleted_ids[:3])})" if deleted_ids else "(deletion)"
elif kind == 7:
e_tags = [t for t in tags if t and t[0] == "e" and len(t) > 1]
if not content.strip() or content == "+":
event_type = EventType.REACTION_REMOVED
content = "(reaction removed)" if e_tags else "(reaction removed)"
elif e_tags:
event_type = EventType.REACTION_ADDED
content = f"reacted with {content}"
else:
event_type = EventType.REACTION_ADDED
content = f"reaction: {content}"
if kind == 4 and self._crypto:
try:
content = self._crypto.decrypt_nip04(content, pubkey)
if self._guard:
plaintext_reject = self._guard.check_plaintext_size(content)
if plaintext_reject:
logger.debug("NIP-04 plaintext rejected: %s", plaintext_reject)
content = f"(plaintext too large: {len(content)} bytes)"
except Exception as e:
logger.debug(
"NIP-04 解密失败: event=%s, sender=%s, error=%s",
event_id[:8],
pubkey[:8],
e,
)
edit_tags = [t for t in tags if t and t[0] == "e" and len(t) > 3 and t[3] == "edit"]
if edit_tags and event_type == EventType.MESSAGE_RECEIVED and kind not in (5, 7):
event_type = EventType.MESSAGE_UPDATED
chat_type = self._resolve_chat_type(kind, tags)
reply_to = self._extract_reply_to(tags)
chat_id = self._resolve_chat_id(chat_type, tags, pubkey)
attachments = self._extract_attachments(raw)
mention_list = self._extract_mentions(content)
mentions = None
if mention_list:
from yuxi.channels.models import MentionsInfo
mentions = MentionsInfo(
mentioned_user_ids=mention_list,
is_bot_mentioned=False,
)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=pubkey,
channel_chat_id=chat_id,
channel_message_id=event_id,
),
message_type=MessageType.TEXT if not attachments else MessageType.IMAGE,
chat_type=ChatType.DIRECT if chat_type == "direct" else ChatType.GROUP,
content=content,
attachments=attachments,
reply_to_message_id=reply_to,
event_type=event_type,
mentions=mentions,
)
def format_outbound(self, response: ChannelResponse) -> Any:
return {"content": response.content, "identity": response.identity.model_dump()}
async def health_check(self) -> HealthStatus:
if not self._relay_manager:
return HealthStatus(status="unhealthy", last_error="RelayManager 未初始化")
active, total = self._relay_manager.active_count()
if total == 0 or active == 0:
return HealthStatus(
status="unhealthy",
last_error="没有 Relay" if total == 0 else "所有 Relay 断连",
)
threshold = self._nostr_config.relay_degraded_threshold if self._nostr_config else 0.5
if active / total < threshold:
return HealthStatus(
status="degraded",
metadata={"active_relays": active, "total_relays": total},
)
health_snapshots = []
for url in self._nostr_config.relays if self._nostr_config else []:
snapshot = self._health_tracker.snapshot(url)
health_snapshots.append(snapshot.url)
return HealthStatus(
status="healthy",
last_connected_at=utc_now_naive(),
metadata={
"active_relays": active,
"total_relays": total,
"npub": self._crypto.npub if self._crypto else "",
},
)
def _resolve_chat_type(self, kind: int, tags: list[list[str]]) -> str:
if kind in (4, 1059):
return "direct"
if kind == 5:
return "group"
if kind == 7:
e_tags = [t for t in tags if t and t[0] == "e"]
p_tags = [t for t in tags if t and t[0] == "p"]
if len(p_tags) == 1 and len(e_tags) <= 1:
return "direct"
return "group"
e_tags = [t for t in tags if t and t[0] == "e"]
if any(len(t) > 3 and t[3] == "root" for t in e_tags):
return "group"
p_tags = [t for t in tags if t and t[0] == "p"]
if len(p_tags) == 1 and len(e_tags) <= 1:
return "direct"
return "group"
def _resolve_chat_id(self, chat_type: str, tags: list[list[str]], pubkey: str) -> str:
if chat_type == "direct":
p_tags = [t for t in tags if t and t[0] == "p"]
other_pubkey = p_tags[0][1] if p_tags and len(p_tags[0]) > 1 else pubkey
return f"dm:{other_pubkey}"
e_tags = [t for t in tags if t and t[0] == "e"]
for tag in e_tags:
if len(tag) > 3 and tag[3] == "root":
return f"channel:{tag[1]}"
if e_tags and len(e_tags[0]) > 1:
return f"channel:{e_tags[0][1]}"
return f"channel:{pubkey}"
def _extract_reply_to(self, tags: list[list[str]]) -> str | None:
e_tags = [t for t in tags if t and t[0] == "e"]
for tag in e_tags:
if len(tag) > 3 and tag[3] in ("reply", "root"):
continue
if len(tag) > 1:
return tag[1]
if e_tags and len(e_tags[-1]) > 1:
return e_tags[-1][1]
return None
def _extract_attachments(self, raw: dict) -> list[Attachment]:
attachments: list[Attachment] = []
tags: list[list[str]] = raw.get("tags", [])
for tag in tags:
if tag and tag[0] == "url" and len(tag) > 1:
url = tag[1]
attachments.append(
Attachment(
type=self._guess_media_type(url),
url=url,
)
)
elif tag and tag[0] == "imeta" and len(tag) > 1:
url = tag[1] if tag[1].startswith("http") else ""
if url:
attachments.append(Attachment(type="image", url=url))
content = raw.get("content", "")
if content.startswith("http") and not attachments:
attachments.append(Attachment(type=self._guess_media_type(content), url=content))
return attachments
@staticmethod
def _guess_media_type(url: str) -> str:
lower = url.lower()
if any(lower.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg")):
return "image"
if any(lower.endswith(ext) for ext in (".mp4", ".webm", ".mov", ".avi")):
return "video"
if any(lower.endswith(ext) for ext in (".mp3", ".wav", ".ogg", ".flac")):
return "audio"
return "file"
@staticmethod
def _extract_mentions(content: str) -> list[str]:
import re
mentions: list[str] = []
patterns = [
r"nostr:(npub1[a-z0-9]+)",
r"nostr:(nprofile1[a-z0-9]+)",
r"nostr:([0-9a-f]{64})",
r"@(npub1[a-z0-9]+)",
]
for pattern in patterns:
mentions.extend(re.findall(pattern, content, re.IGNORECASE))
result: list[str] = []
seen: set[str] = set()
for m in mentions:
norm = normalize_pubkey(m)
if norm and norm not in seen:
seen.add(norm)
result.append(norm)
return result
def _on_relay_event(self, raw_event: dict) -> None:
if self._nostr_config and self._nostr_config.message_ordering:
asyncio.create_task(self._buffer_event(raw_event))
return
task = asyncio.ensure_future(self._handle_relay_event(raw_event))
if not hasattr(self, "_pending_tasks"):
self._pending_tasks: list[asyncio.Task] = []
self._pending_tasks = [t for t in self._pending_tasks if not t.done()]
self._pending_tasks.append(task)
async def _handle_relay_event(self, raw_event: dict) -> None:
event_id = raw_event.get("id", "")
kind = raw_event.get("kind", 0)
self._metrics.record_event_received(kind)
if self._guard:
self._metrics.record_inflight_size(len(self._guard.inflight) + 1)
try:
if self._crypto and not self._crypto.verify_event(raw_event):
event_short = event_id[:8]
pubkey_short = raw_event.get("pubkey", "?")[:8]
self._metrics.record_event_rejected("signature_invalid")
logger.warning(f"[Nostr] 签名验证失败,丢弃: id={event_short}, pubkey={pubkey_short}")
return
if self._guard:
reject_reason = self._guard.check(raw_event)
if reject_reason:
self._metrics.record_event_rejected(reject_reason.replace(" ", "_"))
logger.debug(f"[Nostr] Guard rejected event: {reject_reason}")
return
if kind == 1059 and self._crypto:
try:
decrypted = await self._crypto.decrypt_nip17(raw_event.get("content", ""))
raw_event = {**raw_event, "content": decrypted, "kind": 1059}
if self._guard:
plaintext_reject = self._guard.check_plaintext_size(decrypted)
if plaintext_reject:
logger.debug("NIP-17 plaintext rejected: %s", plaintext_reject)
raw_event["content"] = f"(plaintext too large: {len(decrypted)} bytes)"
except Exception as e:
logger.debug(
"NIP-17 解密失败: event=%s, sender=%s, error=%s",
event_id[:8],
raw_event.get("pubkey", "?")[:8],
e,
)
self._metrics.record_event_accepted()
message = self.normalize_inbound(raw_event)
await self._handle_message(message)
finally:
if self._guard and event_id:
self._guard.done_processing(event_id)
async def _on_relay_connected_metrics(self, url: str) -> None:
self._metrics.record_relay_connect(url)
async def _on_relay_disconnected_metrics(self, url: str) -> None:
self._metrics.record_relay_disconnect(url)
async def _buffer_event(self, raw_event: dict) -> None:
async with self._order_lock:
self._order_buffer.append(raw_event)
async def _run_order_buffer(self) -> None:
window_ms = self._nostr_config.message_ordering_window_ms if self._nostr_config else 500
interval = max(window_ms / 2, 100) / 1000
while True:
try:
await asyncio.sleep(interval)
await self._flush_order_buffer()
except asyncio.CancelledError:
await self._flush_order_buffer()
break
async def _flush_order_buffer(self) -> None:
async with self._order_lock:
if not self._order_buffer:
return
batch = self._order_buffer
self._order_buffer = []
batch.sort(key=lambda e: e.get("created_at", 0))
for raw_event in batch:
task = asyncio.ensure_future(self._handle_relay_event(raw_event))
if not hasattr(self, "_pending_tasks"):
self._pending_tasks: list[asyncio.Task] = []
self._pending_tasks = [t for t in self._pending_tasks if not t.done()]
self._pending_tasks.append(task)
def _resolve_state_dir(self) -> str:
from pathlib import Path
state_base = Path.home() / ".forcepilot" / "channel_states" / "nostr"
return str(state_base)
async def list_peers(self, timeout: float = 15.0) -> list[dict]:
if not self._relay_manager or not self._crypto:
return []
own_pubkey = self._crypto.pubkey_hex()
filters_kind4 = [{"kinds": [4], "authors": [own_pubkey], "limit": 200}]
filters_kind0 = [{"kinds": [0], "limit": 200}]
known_pubkeys: dict[str, dict] = {}
try:
kind4_events = await self._relay_manager.query(filters_kind4, timeout=timeout)
for event in kind4_events:
tags = event.get("tags", [])
p_tags = [t for t in tags if t and t[0] == "p" and len(t) > 1]
for pt in p_tags:
pk = pt[1]
if pk not in known_pubkeys:
known_pubkeys[pk] = {"pubkey": pk, "source": "kind4"}
except Exception:
logger.debug("[Nostr] list_peers kind4 query failed", exc_info=True)
try:
kind0_events = await self._relay_manager.query(filters_kind0, timeout=timeout)
for event in kind0_events:
pk = event.get("pubkey", "")
if pk and pk not in known_pubkeys:
known_pubkeys[pk] = {"pubkey": pk, "source": "kind0"}
except Exception:
logger.debug("[Nostr] list_peers kind0 query failed", exc_info=True)
return list(known_pubkeys.values())
def get_metrics_snapshot(self) -> dict:
if self._guard:
self._metrics.record_seen_tracker_size(len(self._guard._seen))
self._metrics.record_inflight_size(len(self._guard.inflight))
snapshot = self._metrics.snapshot()
return {
"counters": snapshot.counters,
"gauges": snapshot.gauges,
"timestamp": snapshot.timestamp,
}
def get_status_snapshot(self) -> dict:
active, total = self._relay_manager.active_count() if self._relay_manager else (0, 0)
relay_status = {}
if self._relay_manager:
for url in self._nostr_config.relays if self._nostr_config else []:
ws = self._relay_manager._connections.get(url)
cb = self._relay_manager._circuit_breakers.get(url)
health = self._health_tracker.snapshot(url)
relay_status[url] = {
"connected": ws is not None and ws.open,
"circuit_state": cb.state if cb else "unknown",
"health_score": health.score,
}
return {
"status": self._status.value,
"npub": self._crypto.npub if self._crypto else "",
"relays": relay_status,
"active_relays": active,
"total_relays": total,
"inflight": len(self._guard.inflight) if self._guard else 0,
"send_cache_size": len(self._send_cache),
}
async def publish_profile(self, profile_data: dict, account_id: str = "default") -> dict | None:
from yuxi.channels.adapters.nostr.profile import NostrProfileManager
manager = NostrProfileManager(
self._crypto,
self._relay_manager,
self._state_store,
)
from yuxi.channels.adapters.nostr.models import NostrProfile
profile = NostrProfile(**profile_data)
return await manager.publish_profile(profile, account_id)
async def import_profile(self, pubkey: str) -> dict | None:
from yuxi.channels.adapters.nostr.profile import NostrProfileManager
manager = NostrProfileManager(self._crypto, self._relay_manager, self._state_store)
result = await manager.import_profile_from_relays(pubkey)
if result:
return result.model_dump()
return None
def get_send_cache(self) -> list[dict]:
return self._send_cache.to_dict_list()