1. 整理多个文件的导入顺序,移除冗余空行和重复导入 2. 为Nostr订阅添加until参数防止重复拉取 3. 实现长消息分块发送并添加间隔等待 4. 新增Relay健康分数同步任务 5. 新增TLS强制检查配置项并支持从环境变量加载 6. 重构状态恢复逻辑适配异步存储 7. 修复反应发送的参数错误 8. 新增线程模拟自动补全消息上下文 9. 添加解密指标统计和更完善的错误日志
280 lines
12 KiB
Python
280 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
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", "allowlist", "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,
|
||
require_tls: bool = True,
|
||
):
|
||
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.require_tls = require_tls
|
||
self._validate()
|
||
|
||
def _validate(self):
|
||
if self.dm_policy == "whitelist":
|
||
self.dm_policy = "allowlist"
|
||
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}")
|
||
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:
|
||
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),
|
||
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]:
|
||
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
|