"""Конфигурация приложения. Значения читаются из переменных окружения / файла .env (см. .env.example). Реальных секретов в коде быть не должно. """ from __future__ import annotations from datetime import date from functools import lru_cache from typing import Annotated from urllib.parse import quote_plus from zoneinfo import ZoneInfo from pydantic import Field, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict class Settings(BaseSettings): """Настройки, общие для бота и админки.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False, ) app_env: str = "dev" bot_token: str = Field(..., description="Токен бота от @BotFather") db_host: str = "localhost" db_port: int = 5432 db_name: str = "dispel_evil" db_user: str = "de_admin" db_password: str = "" db_pool_min: int = 1 db_pool_max: int = 10 db_statement_timeout_ms: int = 15000 #: Основной чат клана (единственный; контуры разделяются отдельными .env). chat_id: int = 0 #: Доп. чат для служебных уведомлений/логов (опционально). alert_chat_id: int = 0 #: Telegram-ID, которым доступ разрешён в обход проверки членства. #: NoDecode — чтобы pydantic-settings не пытался JSON-декодировать значение #: (поддерживаем формат "1,2,3" и пустое значение через валидатор ниже). admin_ids: Annotated[list[int], NoDecode] = Field(default_factory=list) timezone: str = "Europe/Moscow" #: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически). heartbeat_path: str = "/tmp/dispelevilbot.heartbeat" #: Общий каталог картинок (Docker volume), который видят бот и админка. media_dir: str = "/app/media" #: Публичный адрес админки (deep-link вход, ссылки из бота). admin_public_url: str = "http://localhost:8000" #: Аналитика событий в Postgres. ANALYTICS_ENABLED=false — полностью выключить. analytics_enabled: bool = True #: Батчинг записи событий: размер батча, интервал сброса и предел буфера. analytics_batch_size: int = 200 analytics_flush_interval: float = 5.0 analytics_buffer_max: int = 5000 #: Сколько секунд бот держит шаблоны сообщений в памяти (правки из админки #: применяются без перезапуска). 0 — читать БД при каждом обращении. template_cache_ttl: float = 45.0 #: Окно пасхального события «Яички». Пусто — автоматически 1 апреля → Православная Пасха. egg_event_start: date | None = None egg_event_end: date | None = None @field_validator("chat_id", "alert_chat_id", mode="before") @classmethod def _empty_int_to_zero(cls, value: object) -> object: """Пустое значение в .env трактуем как 0 (а не ошибку валидации).""" if value is None or value == "": return 0 return value @field_validator("egg_event_start", "egg_event_end", mode="before") @classmethod def _empty_date_to_none(cls, value: object) -> object: """Пустое значение в .env — «даты не заданы» (работает авто-расчёт).""" if value is None or value == "": return None return value @field_validator("admin_ids", mode="before") @classmethod def _parse_admin_ids(cls, value: object) -> object: """Поддержать формат 'ADMIN_IDS=1,2,3' в дополнение к JSON-списку.""" if value is None or value == "": return [] if isinstance(value, str): cleaned = value.strip().strip("[]") if not cleaned: return [] return [int(item.strip()) for item in cleaned.split(",") if item.strip()] return value @property def is_prod(self) -> bool: return self.app_env.strip().lower() in {"prod", "production"} @property def tzinfo(self) -> ZoneInfo: return ZoneInfo(self.timezone) @property def dsn(self) -> str: """Строка подключения psycopg для async-пула (креды URL-экранируются).""" user = quote_plus(self.db_user) password = quote_plus(self.db_password) return f"postgresql://{user}:{password}" f"@{self.db_host}:{self.db_port}/{self.db_name}" @lru_cache def get_settings() -> Settings: """Вернуть singleton настроек бота.""" return Settings() # type: ignore[call-arg] class AdminSettings(Settings): """Настройки сервиса админки. Наследует общие поля (окружение, Postgres, таймзона), но не требует ``BOT_TOKEN``: на этапе каркаса он не нужен, а появится в проверке подписи Telegram-логина (Блок 4.2). """ bot_token: str = "" #: Адрес и порт, которые слушает uvicorn внутри контейнера. admin_host: str = "0.0.0.0" admin_port: int = 8000 #: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS). #: Наследуется из базовых настроек: ADMIN_PUBLIC_URL. #: Username бота без @ — из него виджет рисует кнопку входа. bot_username: str = "" #: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают #: при перезапуске (генерируется временный). admin_session_secret: str = "" #: Время жизни сессии, секунды (по умолчанию 12 часов). admin_session_max_age: int = 12 * 60 * 60 @lru_cache def get_admin_settings() -> AdminSettings: """Вернуть singleton настроек админки.""" return AdminSettings() # type: ignore[call-arg]