"""Typed configuration from the environment / `.env` — one settings class per subsystem (ARCHITECTURE §2.11).""" from pathlib import Path from typing import Annotated, Literal from pydantic import Field, SecretStr, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict _ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") class TelegramSettings(BaseSettings): model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_") bot_token: SecretStr class AccessSettings(BaseSettings): model_config = _ENV # Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2 admin_ids: Annotated[frozenset[int], NoDecode] = frozenset() invite_ttl_hours: float = Field(default=24.0, gt=0) # More failed /start per hour than this — silence (SPEC A-6) max_failed_attempts: int = Field(default=5, ge=1) @field_validator("admin_ids", mode="before") @classmethod def _split_ids(cls, value: object) -> object: if isinstance(value, str): return frozenset(int(part) for part in value.replace(" ", "").split(",") if part) return value class LLMSettings(BaseSettings): model_config = SettingsConfigDict(**_ENV, env_prefix="LLM_") # mock: deterministic offline answers (tests, demo without network); openai_compat: any OpenAI-style API (ADR-008) provider: Literal["mock", "openai_compat"] = "mock" base_url: str = "" model: str = "" api_key: SecretStr = SecretStr("") timeout: float = Field(default=30.0, gt=0) max_attempts: int = Field(default=3, ge=1) temperature: float = Field(default=0.0, ge=0, le=2) class DatabaseSettings(BaseSettings): model_config = SettingsConfigDict(**_ENV, env_prefix="DATABASE_") # Compose sets a PostgreSQL URL; the default lets the bot run locally without Docker. url: str = "sqlite+aiosqlite:///./tgit.db" class SourceCraftSettings(BaseSettings): model_config = SettingsConfigDict(**_ENV, env_prefix="SOURCECRAFT_") token: SecretStr = SecretStr("") api_url: str = "https://api.sourcecraft.tech" uploads_repo: str = "rogachevegorushka/tgit-uploads" # Web link to a CI run. The SPA answers 200 on any path, so the format is unverified — adjust if it 404s in UI. run_url_template: str = "https://sourcecraft.dev/{repo}/cicd/runs/{run}" timeout: float = Field(default=30.0, gt=0) max_attempts: int = Field(default=5, ge=1) class GitSettings(BaseSettings): model_config = SettingsConfigDict(**_ENV, env_prefix="GIT_") # The bot's own deploy key with write access to tgit-uploads only (#7) uploads_url: str = "git@ssh.sourcecraft.dev:rogachevegorushka/tgit-uploads.git" ssh_key_path: Path | None = None # Alternative for containers: the private key itself, base64 — a mounted host file is unreadable for the # container user (uid 10001 vs the owner's 0600). Written to a private temp file at startup. ssh_key_b64: SecretStr | None = None branch: str = "main" clone_dir: Path = Path("data/uploads") # Browsing re-fetches at most this often; a push always fetches first (#9) fetch_interval: float = Field(default=10.0, ge=0) timeout: float = Field(default=60.0, gt=0) class RuntimeSettings(BaseSettings): model_config = _ENV log_level: str = "INFO" # Non-secret configs (texts, thresholds, prompts). Relative to the working dir: /srv/tgit in Docker. config_dir: Path = Path("config") # The heartbeat file is touched while the event loop is alive; the Docker healthcheck reads its age. heartbeat_file: Path = Path("/tmp/tgit-heartbeat") heartbeat_interval: float = Field(default=10.0, gt=0) heartbeat_max_age: float = Field(default=30.0, gt=0) class Settings(BaseSettings): model_config = _ENV telegram: TelegramSettings = Field(default_factory=TelegramSettings) access: AccessSettings = Field(default_factory=AccessSettings) database: DatabaseSettings = Field(default_factory=DatabaseSettings) llm: LLMSettings = Field(default_factory=LLMSettings) sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings) git: GitSettings = Field(default_factory=GitSettings) runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)