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", "group"], reactions=True, edit=True, unsend=True, reply=True, media=True, 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 未初始化") receiver_pubkey = "" chat_type = "direct" if ":" in chat_id: parts = chat_id.split(":") if parts[0] == "dm": receiver_pubkey = parts[-1] chat_type = "direct" else: chat_type = "group" try: return await self._sender.send_delete(msg_id, receiver_pubkey, chat_type) 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] streaming_mode = self._nostr_config.streaming_mode if self._nostr_config else "block" if streaming_mode == "off": if finished: identity = self._build_stream_identity(chat_id, msg_id) response = ChannelResponse(identity=identity, content=chunk) return await self.send(response) return DeliveryResult(success=True, message_id=None) if streaming_mode == "block": 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) return DeliveryResult(success=True, message_id=None) # progress 模式:每块都发送编辑 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}" 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: p_tags = [t for t in tags if t and t[0] == "p"] if len(p_tags) == 1: return "direct" 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._guard: since_ts = self._monitor._last_subscribe_ts if self._monitor else 0 reject_reason = self._guard.check(raw_event, since_ts=since_ts) if reject_reason: self._metrics.record_event_rejected(reject_reason.replace(" ", "_")) logger.debug(f"[Nostr] Guard rejected event: {reject_reason}") return 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 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, ) if kind == 4 and self._crypto: try: content = self._crypto.decrypt_nip04(raw_event.get("content", ""), raw_event.get("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)" raw_event["content"] = content except Exception as e: logger.debug( "NIP-04 解密失败: 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()