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

189 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
class NostrConfigError(Exception):
pass
@dataclass
class RateLimitConfig:
window_ms: int = 10_000
max_per_sender_per_window: int = 20
max_global_per_window: int = 200
@dataclass
class GuardPolicyConfig:
allowed_kinds: list[int] = field(default_factory=lambda: [1, 4, 5, 7, 1059])
max_ciphertext_bytes: int = 50_000
max_plaintext_bytes: int = 10_000
max_future_skew_sec: int = 30
rate_limit: RateLimitConfig = field(default_factory=RateLimitConfig)
class NostrConfig:
VALID_DM_POLICIES = {"pairing", "open", "whitelist", "disabled"}
VALID_STREAMING_MODES = {"off", "block", "progress"}
VALID_MARKDOWN_TABLE_MODES = {"off", "convert"}
CHANNEL_ENV_VARS = ["NOSTR_PRIVATE_KEY"]
def __init__(
self,
private_key: str = "",
relays: list[str] | None = None,
dm_policy: str = "pairing",
nip17_enabled: bool = True,
streaming_mode: str = "block",
reconnect_interval_sec: int = 5,
relay_timeout_sec: int = 30,
relay_degraded_threshold: float = 0.5,
nip42_auth_enabled: bool = False,
nip42_auth_urls: list[str] | None = None,
guard_policy: GuardPolicyConfig | None = None,
backfill_window_sec: int = 120,
allow_from: list[str] | None = None,
markdown_table_mode: str = "off",
profile: dict | None = None,
enabled: bool = True,
name: str = "",
send_message_cache_size: int = 100,
message_ordering: bool = False,
message_ordering_window_ms: int = 500,
multi_account_enabled: bool = False,
):
self.private_key = private_key or ""
self.relays = relays or [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.info",
"wss://nos.lol",
]
self.dm_policy = dm_policy
self.nip17_enabled = nip17_enabled
self.streaming_mode = streaming_mode
self.reconnect_interval_sec = reconnect_interval_sec
self.relay_timeout_sec = relay_timeout_sec
self.relay_degraded_threshold = relay_degraded_threshold
self.nip42_auth_enabled = nip42_auth_enabled
self.nip42_auth_urls = nip42_auth_urls or []
self.guard_policy = guard_policy or GuardPolicyConfig()
self.backfill_window_sec = backfill_window_sec
self.allow_from = allow_from or []
self.markdown_table_mode = markdown_table_mode
self.profile = profile
self.enabled = enabled
self.name = name
self.send_message_cache_size = send_message_cache_size
self.message_ordering = message_ordering
self.message_ordering_window_ms = message_ordering_window_ms
self.multi_account_enabled = multi_account_enabled
self._validate()
def _validate(self):
if self.dm_policy not in self.VALID_DM_POLICIES:
raise NostrConfigError(f"dm_policy 值无效: '{self.dm_policy}',应为 {self.VALID_DM_POLICIES}")
if self.streaming_mode not in self.VALID_STREAMING_MODES:
raise NostrConfigError(f"streaming_mode 值无效: '{self.streaming_mode}',应为 {self.VALID_STREAMING_MODES}")
if self.markdown_table_mode not in self.VALID_MARKDOWN_TABLE_MODES:
raise NostrConfigError(
f"markdown_table_mode 值无效: '{self.markdown_table_mode}',应为 {self.VALID_MARKDOWN_TABLE_MODES}"
)
if self.reconnect_interval_sec < 1:
raise NostrConfigError(f"reconnect_interval_sec 不能小于 1当前值: {self.reconnect_interval_sec}")
if self.relay_timeout_sec < 1:
raise NostrConfigError(f"relay_timeout_sec 不能小于 1当前值: {self.relay_timeout_sec}")
if not (0 < self.relay_degraded_threshold <= 1):
raise NostrConfigError(
f"relay_degraded_threshold 应在 (0, 1] 范围内,当前值: {self.relay_degraded_threshold}"
)
for url in self.relays:
if not url.startswith(("ws://", "wss://")):
raise NostrConfigError(f"Relay URL 必须以 ws:// 或 wss:// 开头: {url}")
@classmethod
def from_dict(cls, config: dict[str, Any] | None) -> NostrConfig:
if not config:
return cls()
accounts = config.get("accounts", {})
default_account = accounts.get("default", config)
guard_dict = default_account.get("guard_policy", None)
guard_policy = None
if guard_dict:
rate_limit_dict = guard_dict.get("rate_limit", {})
if not rate_limit_dict:
rate_limit_dict = {
"window_ms": guard_dict.get("rate_limit_window_ms", 10_000),
"max_per_sender_per_window": guard_dict.get("rate_limit_max_per_sender_per_window", 20),
"max_global_per_window": guard_dict.get("rate_limit_max_global_per_window", 200),
}
rate_limit = RateLimitConfig(
window_ms=rate_limit_dict.get("window_ms", 10_000),
max_per_sender_per_window=rate_limit_dict.get("max_per_sender_per_window", 20),
max_global_per_window=rate_limit_dict.get("max_global_per_window", 200),
)
guard_policy = GuardPolicyConfig(
allowed_kinds=guard_dict.get("allowed_kinds", [1, 4, 5, 7, 1059]),
max_ciphertext_bytes=guard_dict.get("max_ciphertext_bytes", 50_000),
max_plaintext_bytes=guard_dict.get("max_plaintext_bytes", 10_000),
max_future_skew_sec=guard_dict.get("max_future_skew_sec", 30),
rate_limit=rate_limit,
)
return cls(
private_key=default_account.get("private_key", ""),
relays=default_account.get("relays"),
dm_policy=default_account.get("dm_policy", "pairing"),
nip17_enabled=default_account.get("nip17_enabled", True),
streaming_mode=default_account.get("streaming_mode", "block"),
reconnect_interval_sec=default_account.get("reconnect_interval_sec", 5),
relay_timeout_sec=default_account.get("relay_timeout_sec", 30),
relay_degraded_threshold=default_account.get("relay_degraded_threshold", 0.5),
nip42_auth_enabled=default_account.get("nip42_auth_enabled", False),
nip42_auth_urls=default_account.get("nip42_auth_urls"),
guard_policy=guard_policy,
backfill_window_sec=default_account.get("backfill_window_sec", 120),
allow_from=default_account.get("allow_from"),
markdown_table_mode=default_account.get("markdown_table_mode", "off"),
profile=default_account.get("profile"),
enabled=default_account.get("enabled", True),
name=default_account.get("name", ""),
send_message_cache_size=default_account.get("send_message_cache_size", 100),
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),
)
def get_account_configs(self, raw_config: dict[str, Any] | None = None) -> dict[str, NostrConfig]:
cfg = raw_config or {}
accounts = cfg.get("accounts", {})
if not accounts:
return {"default": NostrConfig.from_dict(cfg) if cfg else NostrConfig()}
result: dict[str, NostrConfig] = {}
for account_id, account_cfg in accounts.items():
if isinstance(account_cfg, dict) and account_cfg.get("enabled", True) is not False:
result[account_id] = NostrConfig.from_dict({"accounts": {account_id: account_cfg}})
if not result:
result["default"] = NostrConfig()
return result
def resolve_default_account_id(self) -> str:
return "default"
def list_account_ids(self, config: dict | None = None) -> list[str]:
cfg = config or {}
accounts = cfg.get("accounts", {})
if not accounts:
return ["default"]
result = []
for account_id, account_cfg in accounts.items():
if isinstance(account_cfg, dict) and account_cfg.get("enabled", True) is not False:
result.append(account_id)
if not result:
return ["default"]
return result