refactor(nostr): 整理代码结构并新增多项功能优化

1.  整理多个文件的导入顺序,移除冗余空行和重复导入
2.  为Nostr订阅添加until参数防止重复拉取
3.  实现长消息分块发送并添加间隔等待
4.  新增Relay健康分数同步任务
5.  新增TLS强制检查配置项并支持从环境变量加载
6.  重构状态恢复逻辑适配异步存储
7.  修复反应发送的参数错误
8.  新增线程模拟自动补全消息上下文
9.  添加解密指标统计和更完善的错误日志
This commit is contained in:
Kris 2026-05-13 16:13:03 +08:00
parent b317387e0c
commit 7002557cf4
10 changed files with 250 additions and 76 deletions

View File

@ -1,16 +1,31 @@
from __future__ import annotations
import asyncio
import json as _json
from dataclasses import dataclass
from typing import Any
import aiohttp
from yuxi.channels.adapters.nostr.config import NostrConfig
from yuxi.channels.adapters.nostr.crypto import NostrCrypto, NostrCryptoError, normalize_pubkey
from yuxi.channels.adapters.nostr.guard import GuardPolicy, NostrGuard
from yuxi.channels.adapters.nostr.health import RelayHealthTracker
from yuxi.channels.adapters.nostr.metrics import NostrMetrics
from yuxi.channels.adapters.nostr.monitor import NostrMonitor
from yuxi.channels.adapters.nostr.probe import probe_relay
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
from yuxi.channels.adapters.nostr.send import NostrSender
from yuxi.channels.adapters.nostr.send_cache import SendCache
from yuxi.channels.adapters.nostr.state_store import NostrStateStore
from yuxi.channels.adapters.nostr.thread_simulator import NostrThreadSimulator
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.meta import ChannelMeta
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
@ -25,24 +40,9 @@ from yuxi.channels.models import (
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:
@ -114,8 +114,10 @@ class NostrAdapter(BaseChannelAdapter):
self._order_buffer: list[dict] = []
self._order_lock = asyncio.Lock()
self._ordering_task: asyncio.Task | None = None
self._score_sync_task: asyncio.Task | None = None
self._account_managers: dict[str, _AccountComponents] = {}
self._active_account_id: str = "default"
self._thread_simulator: NostrThreadSimulator | None = None
async def connect(self) -> None:
self._status = ChannelStatus.CONNECTING
@ -143,6 +145,8 @@ class NostrAdapter(BaseChannelAdapter):
await self._relay_manager.connect_all()
self._thread_simulator = NostrThreadSimulator(relay_pool=self._relay_manager, max_depth=10)
for components in self._account_managers.values():
if components.relay_manager is not self._relay_manager:
await components.relay_manager.connect_all()
@ -163,6 +167,8 @@ class NostrAdapter(BaseChannelAdapter):
if self._nostr_config.message_ordering:
self._ordering_task = asyncio.create_task(self._run_order_buffer())
self._score_sync_task = asyncio.create_task(self._run_score_sync())
self._status = ChannelStatus.CONNECTED
account_count = 1 + len(self._account_managers)
logger.info(f"[Nostr] Channel connected. npub: {self._crypto.npub}, 账户数: {account_count}")
@ -192,7 +198,7 @@ class NostrAdapter(BaseChannelAdapter):
guard_policy = self._build_guard_policy(cfg)
self._guard = NostrGuard(self._crypto.pubkey_hex(), guard_policy)
self._restore_state()
await self._restore_state()
async def _init_additional_account(self, account_id: str, cfg: NostrConfig) -> None:
try:
@ -247,20 +253,28 @@ class NostrAdapter(BaseChannelAdapter):
allow_from=cfg.allow_from,
)
def _restore_state(self) -> None:
saved_events = self._state_store.load_seen_events()
async def _restore_state(self) -> None:
saved_events = await self.state_get("seen_events", namespace="dedup")
if saved_events is 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()
saved_inflight = await self.state_get("inflight", namespace="dedup")
if saved_inflight is None:
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()
last_ts = await self.state_get("last_processed_at", namespace="connection")
if last_ts is None:
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
@ -296,16 +310,42 @@ class NostrAdapter(BaseChannelAdapter):
if failed:
logger.warning(f"[Nostr] NIP-42 AUTH 最终失败 {len(failed)} 个 Relay: {failed}")
total_urls = len(auth_urls)
success_count = total_urls - len(failed)
self._metrics.increment("nip42_auth.success", success_count)
self._metrics.increment("nip42_auth.failure", len(failed))
self._metrics.set_gauge("nip42_auth.success_rate", success_count / max(total_urls, 1))
if failed and len(failed) == total_urls:
logger.warning("[Nostr] 所有 Relay NIP-42 AUTH 失败,适配器进入 degraded 状态")
self._status = ChannelStatus.CONNECTED
for url in failed:
self._health_tracker.record_failure(url)
async def disconnect(self) -> None:
if self._ordering_task:
self._ordering_task.cancel()
self._ordering_task = None
if self._score_sync_task:
self._score_sync_task.cancel()
self._score_sync_task = None
if self._state_store:
self._state_store.save_last_processed_at(self._monitor._last_subscribe_ts if self._monitor else 0)
last_ts = self._monitor._last_subscribe_ts if self._monitor else 0
self._state_store.save_last_processed_at(last_ts)
await self.state_set("last_processed_at", last_ts, namespace="connection")
self._state_store.save_send_cache(self._send_cache.to_dict_list())
send_cache_data = self._send_cache.to_dict_list()
await self.state_set("send_cache", send_cache_data, namespace="cache", ttl_seconds=3600)
if self._guard:
seen_events = list(self._guard._seen._store.keys())
self._state_store.save_inflight(list(self._guard.inflight))
inflight_data = list(self._guard.inflight)
await self.state_set("seen_events", seen_events, namespace="dedup", ttl_seconds=600)
await self.state_set("inflight", inflight_data, namespace="dedup", ttl_seconds=600)
tasks = []
if self._monitor:
tasks.append(self._monitor.stop())
@ -390,20 +430,18 @@ class NostrAdapter(BaseChannelAdapter):
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)
target_pubkey = ""
if ":" in chat_id:
target_pubkey = chat_id.split(":")[-1]
return await self._sender.send_reaction(msg_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]
target_pubkey = ""
if ":" in chat_id:
target_pubkey = chat_id.split(":")[-1]
return await self._sender.remove_reaction(msg_id, target_pubkey)
async def send_chat_action(self, chat_id: str, action: str) -> DeliveryResult:
@ -424,43 +462,38 @@ class NostrAdapter(BaseChannelAdapter):
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 await self._flush_stream_final(chat_id, msg_id, chunk, receiver_pubkey)
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 await self._flush_stream_final(chat_id, msg_id, chunk, receiver_pubkey)
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)
return await self._flush_stream_final(chat_id, msg_id, chunk, receiver_pubkey)
return await self._flush_stream_edit_or_send(chat_id, msg_id, chunk, receiver_pubkey)
async def _flush_stream_final(self, chat_id: str, msg_id: str, chunk: str, receiver_pubkey: str) -> DeliveryResult:
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 _flush_stream_edit_or_send(
self, chat_id: str, msg_id: str, chunk: str, receiver_pubkey: str
) -> DeliveryResult:
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)
@ -807,22 +840,26 @@ class NostrAdapter(BaseChannelAdapter):
try:
decrypted = await self._crypto.decrypt_nip17(raw_event.get("content", ""))
raw_event = {**raw_event, "content": decrypted, "kind": 1059}
self._metrics.record_decrypt_success("nip17")
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(
self._metrics.record_decrypt_failure("nip17")
logger.warning(
"NIP-17 解密失败: event=%s, sender=%s, error=%s",
event_id[:8],
raw_event.get("pubkey", "?")[:8],
e,
)
return
if kind == 4 and self._crypto:
try:
content = self._crypto.decrypt_nip04(raw_event.get("content", ""), raw_event.get("pubkey", ""))
self._metrics.record_decrypt_success("nip04")
if self._guard:
plaintext_reject = self._guard.check_plaintext_size(content)
if plaintext_reject:
@ -830,15 +867,28 @@ class NostrAdapter(BaseChannelAdapter):
content = f"(plaintext too large: {len(content)} bytes)"
raw_event["content"] = content
except Exception as e:
logger.debug(
self._metrics.record_decrypt_failure("nip04")
logger.warning(
"NIP-04 解密失败: event=%s, sender=%s, error=%s",
event_id[:8],
raw_event.get("pubkey", "?")[:8],
e,
)
return
self._metrics.record_event_accepted()
message = self.normalize_inbound(raw_event)
if self._thread_simulator and kind not in (5, 7):
try:
thread_chain = await self._thread_simulator.build_thread_from_event(raw_event)
if len(thread_chain) > 1:
parent_ids = [e.get("id", "") for e in thread_chain[:-1]]
message.metadata = message.metadata or {}
message.metadata["thread_parent_ids"] = parent_ids
except Exception:
pass
await self._handle_message(message)
finally:
if self._guard and event_id:
@ -882,6 +932,16 @@ class NostrAdapter(BaseChannelAdapter):
self._pending_tasks = [t for t in self._pending_tasks if not t.done()]
self._pending_tasks.append(task)
async def _run_score_sync(self) -> None:
interval = 30.0
try:
while True:
await asyncio.sleep(interval)
if self._relay_manager:
self._relay_manager.sync_scores_from_health_tracker(self._health_tracker)
except asyncio.CancelledError:
pass
def _resolve_state_dir(self) -> str:
from pathlib import Path

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any
@ -53,6 +54,7 @@ class NostrConfig:
message_ordering: bool = False,
message_ordering_window_ms: int = 500,
multi_account_enabled: bool = False,
require_tls: bool = True,
):
self.private_key = private_key or ""
self.relays = relays or [
@ -80,6 +82,7 @@ class NostrConfig:
self.message_ordering = message_ordering
self.message_ordering_window_ms = message_ordering_window_ms
self.multi_account_enabled = multi_account_enabled
self.require_tls = require_tls
self._validate()
def _validate(self):
@ -104,6 +107,10 @@ class NostrConfig:
for url in self.relays:
if not url.startswith(("ws://", "wss://")):
raise NostrConfigError(f"Relay URL 必须以 ws:// 或 wss:// 开头: {url}")
if self.require_tls and url.startswith("ws://"):
raise NostrConfigError(
f"Relay URL 不允许明文 ws:// (require_tls=True): {url}。请使用 wss:// 或将 require_tls 设为 False。"
)
@classmethod
def from_dict(cls, config: dict[str, Any] | None) -> NostrConfig:
@ -157,6 +164,88 @@ class NostrConfig:
message_ordering=default_account.get("message_ordering", False),
message_ordering_window_ms=default_account.get("message_ordering_window_ms", 500),
multi_account_enabled=default_account.get("multi_account_enabled", False),
require_tls=default_account.get("require_tls", True),
)
@classmethod
def from_env(cls, prefix: str = "NOSTR_") -> NostrConfig:
def _env(key: str, default: Any = None) -> Any:
return os.getenv(prefix + key, default)
def _env_bool(key: str, default: bool = False) -> bool:
val = os.getenv(prefix + key)
if val is None:
return default
return val.lower() in ("1", "true", "yes", "on")
def _env_int(key: str, default: int = 0) -> int:
val = os.getenv(prefix + key)
if val is None:
return default
try:
return int(val)
except ValueError:
return default
def _env_float(key: str, default: float = 0.0) -> float:
val = os.getenv(prefix + key)
if val is None:
return default
try:
return float(val)
except ValueError:
return default
def _env_list(key: str, default: list[str] | None = None) -> list[str] | None:
val = os.getenv(prefix + key)
if val is None:
return default
return [item.strip() for item in val.split(",") if item.strip()]
relays = _env_list("RELAYS") or [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.info",
"wss://nos.lol",
]
guard_policy = GuardPolicyConfig(
allowed_kinds=_env_list("GUARD_ALLOWED_KINDS") or [1, 4, 5, 7, 1059],
max_ciphertext_bytes=_env_int("GUARD_MAX_CIPHERTEXT_BYTES", 50_000),
max_plaintext_bytes=_env_int("GUARD_MAX_PLAINTEXT_BYTES", 10_000),
max_future_skew_sec=_env_int("GUARD_MAX_FUTURE_SKEW_SEC", 30),
rate_limit=RateLimitConfig(
window_ms=_env_int("RATE_LIMIT_WINDOW_MS", 10_000),
max_per_sender_per_window=_env_int("RATE_LIMIT_MAX_PER_SENDER", 20),
max_global_per_window=_env_int("RATE_LIMIT_MAX_GLOBAL", 200),
),
)
allow_from = _env_list("ALLOW_FROM")
return cls(
private_key=_env("PRIVATE_KEY", ""),
relays=relays,
dm_policy=_env("DM_POLICY", "pairing"),
nip17_enabled=_env_bool("NIP17_ENABLED", True),
streaming_mode=_env("STREAMING_MODE", "block"),
reconnect_interval_sec=_env_int("RECONNECT_INTERVAL_SEC", 5),
relay_timeout_sec=_env_int("RELAY_TIMEOUT_SEC", 30),
relay_degraded_threshold=_env_float("RELAY_DEGRADED_THRESHOLD", 0.5),
nip42_auth_enabled=_env_bool("NIP42_AUTH_ENABLED", False),
nip42_auth_urls=_env_list("NIP42_AUTH_URLS"),
guard_policy=guard_policy,
backfill_window_sec=_env_int("BACKFILL_WINDOW_SEC", 120),
allow_from=allow_from,
markdown_table_mode=_env("MARKDOWN_TABLE_MODE", "off"),
profile=None,
enabled=_env_bool("ENABLED", True),
name=_env("NAME", ""),
send_message_cache_size=_env_int("SEND_CACHE_SIZE", 100),
message_ordering=_env_bool("MESSAGE_ORDERING", False),
message_ordering_window_ms=_env_int("MESSAGE_ORDERING_WINDOW_MS", 500),
multi_account_enabled=_env_bool("MULTI_ACCOUNT_ENABLED", False),
require_tls=_env_bool("REQUIRE_TLS", True),
)
def get_account_configs(self, raw_config: dict[str, Any] | None = None) -> dict[str, NostrConfig]:

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import json as _json
import time
from collections import OrderedDict, defaultdict
from dataclasses import dataclass, field
@ -7,8 +8,6 @@ from dataclasses import dataclass, field
from yuxi.channels.adapters.nostr.crypto import normalize_pubkey
from yuxi.utils.logging_config import logger
import json as _json
@dataclass
class AuditRecord:

View File

@ -55,7 +55,7 @@ class NostrMonitor:
self._last_subscribe_ts = now - self._backfill_window_sec
since = self._last_subscribe_ts
self._last_subscribe_ts = now
filters = [{"kinds": [1, 4, 5, 7, 1059], "since": since}]
filters = [{"kinds": [1, 4, 5, 7, 1059], "since": since, "until": now + 60}]
self._relay_manager.set_pubkey_filter([self._crypto.pubkey_hex()])
await self._relay_manager.subscribe(filters)
self._subscription_active = True

View File

@ -2,17 +2,16 @@ from __future__ import annotations
import asyncio
import ipaddress
import json as _json
import time
from urllib.parse import urlparse
from yuxi.channels.adapters.nostr.crypto import NostrCrypto
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
from yuxi.channels.adapters.nostr.models import NostrProfile
from yuxi.channels.adapters.nostr.relay_manager import RelayManager
from yuxi.channels.adapters.nostr.state_store import NostrStateStore
from yuxi.utils.logging_config import logger
import json as _json
def _validate_profile_url(url: str, field_name: str = "url") -> str | None:
if not url:

View File

@ -1,6 +1,5 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from yuxi.channels.adapters.nostr.models import NostrProfile

View File

@ -296,9 +296,19 @@ class RelayManager:
pass
self._schedule_reconnect(url)
async def query(self, filters: list[dict], timeout: float = 10.0) -> list[dict]:
async def query(
self, filters: list[dict], timeout: float = 10.0, since: int | None = None, until: int | None = None
) -> list[dict]:
sub_id = "forcepilot_nostr_query"
req = json.dumps(["REQ", sub_id, *filters])
effective_filters: list[dict] = []
for f in filters:
f_copy = dict(f)
if since is not None:
f_copy["since"] = since
if until is not None:
f_copy["until"] = until
effective_filters.append(f_copy)
req = json.dumps(["REQ", sub_id, *effective_filters])
close_req = json.dumps(["CLOSE", sub_id])
active_ws = [(url, ws) for url, ws in self._connections.items() if ws and ws.open]
@ -329,6 +339,11 @@ class RelayManager:
pass
except Exception:
pass
finally:
try:
await _ws.send(close_req)
except Exception:
pass
return results
receive_tasks = [_receive_from_relay(url, ws) for url, ws in active_ws]
@ -343,12 +358,6 @@ class RelayManager:
seen.add(event_id)
events.append(event)
for url, ws in active_ws:
try:
await ws.send(close_req)
except Exception:
pass
return events
async def send_auth(self, raw_signed_auth_event: dict, urls: list[str] | None = None) -> dict[str, bool]:
@ -378,5 +387,15 @@ class RelayManager:
def set_relay_scores(self, scores: dict[str, float]) -> None:
self._relay_scores.update(scores)
def sync_scores_from_health_tracker(self, health_tracker, urls: list[str] | None = None) -> None:
target_urls = urls or list(self._relay_urls)
for url in target_urls:
score = health_tracker.get_score(url)
self._relay_scores[url] = score
def get_relay_score(self, url: str) -> float:
return self._relay_scores.get(url, 0.5)
async def query_event(self, event_id: str, timeout: float = 10.0) -> dict | None:
events = await self.query([{"ids": [event_id], "limit": 1}], timeout=timeout)
return events[0] if events else None

View File

@ -1,13 +1,14 @@
from __future__ import annotations
import asyncio
import json as _json
import re
import time
from yuxi.channels.models import ChannelResponse, DeliveryResult
from yuxi.channels.adapters.nostr.crypto import NostrCrypto, NostrCryptoError
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.models import ChannelResponse, DeliveryResult
from yuxi.utils.logging_config import logger
@ -22,8 +23,9 @@ class NostrSender:
async def send(self, response: ChannelResponse) -> DeliveryResult:
try:
chunks = self._chunk_text(response.content)
total = len(chunks)
results = []
for chunk in chunks:
for i, chunk in enumerate(chunks):
chunk_response = ChannelResponse(
identity=response.identity,
content=chunk,
@ -31,8 +33,14 @@ class NostrSender:
reply_to_message_id=response.reply_to_message_id,
)
event = await self.format_outbound(chunk_response)
if total > 1:
tags = event.get("tags", [])
tags.append(["chunk", f"{i + 1}/{total}"])
event["tags"] = tags
success_count = await self._relay_manager.broadcast(event)
results.append(success_count > 0)
if total > 1 and i < total - 1:
await asyncio.sleep(0.2)
all_success = all(results)
event = await self.format_outbound(response)
event_id = event.get("id", "")

View File

@ -1,9 +1,10 @@
from __future__ import annotations
import asyncio
from yuxi.channels.adapters.nostr.crypto import NostrCrypto, NostrCryptoError
from yuxi.channels.adapters.nostr.probe import probe_relay
from yuxi.utils.logging_config import logger
import asyncio
class NostrSetupAdapter:

View File

@ -2,9 +2,9 @@ from __future__ import annotations
from typing import Any
from yuxi.channels.adapters.nostr.setup import NostrSetupAdapter
from yuxi.channels.adapters.nostr.crypto import NostrCrypto, NostrCryptoError
from yuxi.channels.adapters.nostr.config import NostrConfig
from yuxi.channels.adapters.nostr.crypto import NostrCrypto, NostrCryptoError
from yuxi.channels.adapters.nostr.setup import NostrSetupAdapter
class NostrSetupPlugin: