这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
985 lines
39 KiB
Python
985 lines
39 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", "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()
|