from __future__ import annotations import os from typing import Any, Literal from pydantic import BaseModel, Field, field_validator from yuxi.utils.logging_config import logger class CatchupConfig(BaseModel): enabled: bool = True max_age_minutes: int = 120 per_run_limit: int = 50 first_run_lookback_minutes: int = 30 max_failure_retries: int = 10 class HealthMonitorConfig(BaseModel): enabled: bool = True class NetworkConfig(BaseModel): dangerously_allow_private_network: bool = False class MarkdownConfig(BaseModel): enabled: bool = True code_blocks: bool = True class BlockStreamingCoalesceConfig(BaseModel): enabled: bool = False max_flush_interval_ms: int = 500 class GroupOverride(BaseModel): system_prompt: str = "" class BlueBubblesConfig(BaseModel): enabled: bool = False name: str = "" server_url: str = Field(default="http://localhost:1234") password: str = Field(default="") request_timeout: float = 30.0 send_timeout_ms: int = 30000 reconnect_max_delay: float = 60.0 dm_policy: Literal["open", "pairing", "allowlist", "blocklist"] = "pairing" group_policy: Literal["open", "pairing", "allowlist", "blocklist"] = "allowlist" dm_allow_from: list[str] = [] group_allow_chats: list[str] = [] group_allow_from: list[str] = [] media_max_mb: int = 100 enable_stickers: bool = True media_local_roots: list[str] = [] streaming_mode: Literal["off", "partial", "block"] = "partial" edit_interval_ms: int = 500 block_streaming: bool = False block_streaming_coalesce: BlockStreamingCoalesceConfig = Field(default_factory=BlockStreamingCoalesceConfig) text_chunk_limit: int = 4000 chunk_mode: Literal["length", "newline"] = "newline" tapback_enabled: bool = True require_mention: bool = False ai_vision_enabled: bool = False streaming_typing_indicator: bool = True message_order_check: bool = True max_cache_entries: int = 2048 send_read_receipts: bool = True dm_history_limit: int = 100 coalesce_same_sender_dms: bool = False enrich_group_participants_from_contacts: bool = True allow_private_network: bool = False network: NetworkConfig = Field(default_factory=NetworkConfig) config_writes: bool = True webhook_path: str = "/bluebubbles/webhook" webhook_secret: str = "" markdown: MarkdownConfig = Field(default_factory=MarkdownConfig) capabilities: list[str] = [] dms: dict[str, dict[str, Any]] = {} default_account: str = "" health_monitor: HealthMonitorConfig = Field(default_factory=HealthMonitorConfig) catchup: CatchupConfig = Field(default_factory=CatchupConfig) groups: dict[str, GroupOverride] = {} auth_strategy: Literal["header", "query", "both"] = "header" auth_header_name: str = "X-BB-Password" tts_prefer_caf: bool = True def __repr__(self) -> str: d = self.model_dump() if d.get("password"): d["password"] = "***" fields = ", ".join(f"{k}={v!r}" for k, v in d.items()) return f"{self.__class__.__name__}({fields})" @field_validator("allow_private_network", mode="before") @classmethod def _deprecate_allow_private_network(cls, v, info) -> Any: if v is True: logger.warning( "[BlueBubbles] 'allow_private_network' is deprecated, " "use 'network.dangerously_allow_private_network' instead" ) return v @classmethod def _validate_literal(cls, value: str, valid_values: list[str], field_name: str, default: str) -> str: if value not in valid_values: logger.warning( "[BlueBubbles] Invalid value '%s' for '%s', falling back to '%s'. Valid values: %s", value, field_name, default, valid_values, ) return default return value @classmethod def from_env(cls) -> BlueBubblesConfig: dm_raw = os.getenv("BLUEBUBBLES_DM_ALLOW_FROM", "") group_raw = os.getenv("BLUEBUBBLES_GROUP_ALLOW_CHATS", "") media_roots_raw = os.getenv("BLUEBUBBLES_MEDIA_LOCAL_ROOTS", "") return cls( enabled=os.getenv("BLUEBUBBLES_ENABLED", "false").lower() == "true", name=os.getenv("BLUEBUBBLES_NAME", ""), server_url=os.getenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234"), password=os.getenv("BLUEBUBBLES_SERVER_PASSWORD", ""), request_timeout=float(os.getenv("BLUEBUBBLES_REQUEST_TIMEOUT", "30.0")), send_timeout_ms=int(os.getenv("BLUEBUBBLES_SEND_TIMEOUT_MS", "30000")), reconnect_max_delay=float(os.getenv("BLUEBUBBLES_RECONNECT_MAX_DELAY", "60.0")), dm_policy=os.getenv("BLUEBUBBLES_DM_POLICY", "pairing"), group_policy=os.getenv("BLUEBUBBLES_GROUP_POLICY", "allowlist"), dm_allow_from=[x.strip() for x in dm_raw.split(",") if x.strip()] if dm_raw else [], group_allow_chats=[x.strip() for x in group_raw.split(",") if x.strip()] if group_raw else [], media_max_mb=int(os.getenv("BLUEBUBBLES_MEDIA_MAX_MB", "100")), enable_stickers=os.getenv("BLUEBUBBLES_ENABLE_STICKERS", "true").lower() == "true", media_local_roots=[x.strip() for x in media_roots_raw.split(",") if x.strip()] if media_roots_raw else [], streaming_mode=cls._validate_literal( os.getenv("BLUEBUBBLES_STREAMING_MODE", "partial"), ["off", "partial", "block"], "streaming_mode", "partial", ), edit_interval_ms=int(os.getenv("BLUEBUBBLES_EDIT_INTERVAL_MS", "500")), tapback_enabled=os.getenv("BLUEBUBBLES_TAPBACK_ENABLED", "true").lower() == "true", require_mention=os.getenv("BLUEBUBBLES_REQUIRE_MENTION", "false").lower() == "true", send_read_receipts=os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() == "true", text_chunk_limit=int(os.getenv("BLUEBUBBLES_TEXT_CHUNK_LIMIT", "4000")), block_streaming=os.getenv("BLUEBUBBLES_BLOCK_STREAMING", "false").lower() == "true", allow_private_network=os.getenv("BLUEBUBBLES_ALLOW_PRIVATE_NETWORK", "false").lower() == "true", webhook_path=os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles/webhook"), webhook_secret=os.getenv("BLUEBUBBLES_WEBHOOK_SECRET", ""), auth_strategy=cls._validate_literal( os.getenv("BLUEBUBBLES_AUTH_STRATEGY", "header"), ["header", "query", "both"], "auth_strategy", "header", ), tts_prefer_caf=os.getenv("BLUEBUBBLES_TTS_PREFER_CAF", "true").lower() == "true", streaming_typing_indicator=os.getenv("BLUEBUBBLES_STREAMING_TYPING", "true").lower() == "true", message_order_check=os.getenv("BLUEBUBBLES_MESSAGE_ORDER_CHECK", "true").lower() == "true", max_cache_entries=int(os.getenv("BLUEBUBBLES_MAX_CACHE_ENTRIES", "2048")), dm_history_limit=int(os.getenv("BLUEBUBBLES_DM_HISTORY_LIMIT", "100")), coalesce_same_sender_dms=os.getenv("BLUEBUBBLES_COALESCE_DMS", "false").lower() == "true", enrich_group_participants_from_contacts=os.getenv("BLUEBUBBLES_ENRICH_GROUP", "true").lower() == "true", config_writes=os.getenv("BLUEBUBBLES_CONFIG_WRITES", "true").lower() == "true", block_streaming_coalesce=BlockStreamingCoalesceConfig( enabled=os.getenv("BLUEBUBBLES_COALESCE_ENABLED", "false").lower() == "true", max_flush_interval_ms=int(os.getenv("BLUEBUBBLES_COALESCE_MAX_FLUSH_MS", "500")), ), network=NetworkConfig( dangerously_allow_private_network=os.getenv( "BLUEBUBBLES_NETWORK_DANGEROUSLY_ALLOW_PRIVATE", "false" ).lower() == "true", ), markdown=MarkdownConfig( enabled=os.getenv("BLUEBUBBLES_MARKDOWN_ENABLED", "true").lower() == "true", code_blocks=os.getenv("BLUEBUBBLES_MARKDOWN_CODE_BLOCKS", "true").lower() == "true", ), catchup=CatchupConfig( enabled=os.getenv("BLUEBUBBLES_CATCHUP_ENABLED", "true").lower() == "true", max_age_minutes=int(os.getenv("BLUEBUBBLES_CATCHUP_MAX_AGE_MINUTES", "120")), per_run_limit=int(os.getenv("BLUEBUBBLES_CATCHUP_PER_RUN_LIMIT", "50")), first_run_lookback_minutes=int(os.getenv("BLUEBUBBLES_CATCHUP_FIRST_LOOKBACK", "30")), max_failure_retries=int(os.getenv("BLUEBUBBLES_CATCHUP_MAX_FAILURES", "10")), ), health_monitor=HealthMonitorConfig( enabled=os.getenv("BLUEBUBBLES_HEALTH_MONITOR_ENABLED", "true").lower() == "true", ), )