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