Files
multi-bot-tg/app/config.py
Azimkin a84467c385 feat: add Claude and OpenRouter AI providers
OpenRouter is used when no provider flag is set. Claude is selected with .cl, and GLM stays available via .glm.
2026-10-13 08:56:16 +00:00

503 lines
18 KiB
Python

from __future__ import annotations
import json
import os
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
try:
from dotenv import load_dotenv
except ModuleNotFoundError: # pragma: no cover - optional dependency fallback
def load_dotenv(*args: Any, **kwargs: Any) -> bool: # type: ignore[override]
return False
@dataclass(slots=True)
class AccountConfig:
name: str
session: str
api_id: int | None = None
api_hash: str | None = None
session_string: str | None = None
phone_number: str | None = None
password: str | None = None
phone_code: str | None = None
bot_token: str | None = None
proxy: dict[str, Any] | str | None = None
workdir: Path | None = None
in_memory: bool = False
no_updates: bool = False
takeout: bool = False
hide_password: bool = False
use_qr: bool = False
@dataclass(slots=True)
class ElevenLabsConfig:
api_key: str | None = None
model_id: str = "scribe_v2"
language_code: str | None = None
request_timeout_seconds: float = 300.0
@dataclass(slots=True)
class AiProviderConfig:
api_url: str
api_key: str | None = None
model: str = ""
@dataclass(slots=True)
class AiConfig:
glm: AiProviderConfig = field(
default_factory=lambda: AiProviderConfig(
api_url="https://api.z.ai/api/paas/v4",
model="glm-5.3",
)
)
deepseek: AiProviderConfig = field(
default_factory=lambda: AiProviderConfig(
api_url="https://api.deepseek.com",
model="deepseek-v4-flash",
)
)
claude: AiProviderConfig = field(
default_factory=lambda: AiProviderConfig(
api_url="https://api.anthropic.com",
model="claude-haiku-5-5",
)
)
openrouter: AiProviderConfig = field(
default_factory=lambda: AiProviderConfig(
api_url="https://openrouter.ai/api/v1",
model="anthropic/claude-haiku-5.5",
)
)
prompt_dir: Path = Path("prompts")
base_dir: Path = Path(".")
max_context_messages: int = 100
max_media_bytes: int = 5 * 1024 * 1024
max_tool_rounds: int = 16
brave_api_key: str | None = None
brave_timeout_seconds: float = 10.0
web_page_timeout_seconds: float = 45.0
web_page_max_chars: int = 60_000
@dataclass(slots=True)
class AppConfig:
api_id: int | None
api_hash: str | None
session_dir: Path
accounts: list[AccountConfig]
elevenlabs: ElevenLabsConfig = field(default_factory=ElevenLabsConfig)
ai: AiConfig = field(default_factory=AiConfig)
def _read_toml_file(path: Path) -> dict[str, Any]:
with path.open("rb") as handle:
return tomllib.load(handle)
def _parse_int(value: Any, *, field_name: str) -> int:
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be an integer") from exc
def _parse_bool(value: Any, *, field_name: str) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
if isinstance(value, int):
return bool(value)
raise ValueError(f"{field_name} must be a boolean")
def _parse_float(value: Any, *, field_name: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be a number") from exc
def _parse_mapping(value: Any, *, field_name: str) -> dict[str, Any]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError(f"{field_name} must be an object")
return value
def _parse_elevenlabs_config(raw: Any) -> ElevenLabsConfig:
raw_config = _parse_mapping(raw, field_name="elevenlabs")
raw_api_key = raw_config.get("api_key")
api_key = str(raw_api_key).strip() if raw_api_key is not None else None
api_key = api_key or None
model_id = str(raw_config.get("model_id", "scribe_v2") or "scribe_v2").strip()
raw_language = raw_config.get("language_code")
language_code = str(raw_language).strip() if raw_language is not None else None
language_code = language_code or None
request_timeout_seconds = _parse_float(
raw_config.get("request_timeout_seconds", 300),
field_name="elevenlabs.request_timeout_seconds",
)
if os.environ.get("ELEVENLABS_API_KEY") is not None:
api_key = os.environ["ELEVENLABS_API_KEY"].strip() or None
if os.environ.get("ELEVENLABS_MODEL_ID") is not None:
model_id = os.environ["ELEVENLABS_MODEL_ID"].strip()
if os.environ.get("ELEVENLABS_LANGUAGE_CODE") is not None:
language_code = os.environ["ELEVENLABS_LANGUAGE_CODE"].strip() or None
if os.environ.get("ELEVENLABS_REQUEST_TIMEOUT_SECONDS") is not None:
request_timeout_seconds = _parse_float(
os.environ["ELEVENLABS_REQUEST_TIMEOUT_SECONDS"],
field_name="ELEVENLABS_REQUEST_TIMEOUT_SECONDS",
)
if not model_id:
raise ValueError("elevenlabs.model_id is required")
if request_timeout_seconds <= 0:
raise ValueError("elevenlabs.request_timeout_seconds must be greater than zero")
return ElevenLabsConfig(
api_key=api_key,
model_id=model_id,
language_code=language_code,
request_timeout_seconds=request_timeout_seconds,
)
def _parse_ai_config(raw: Any) -> AiConfig:
raw_config = _parse_mapping(raw, field_name="ai")
raw_prompt_dir = raw_config.get("prompt_dir", "prompts")
prompt_dir_value = str(raw_prompt_dir).strip()
if os.environ.get("AI_PROMPT_DIR") is not None:
prompt_dir_value = os.environ["AI_PROMPT_DIR"].strip()
if not prompt_dir_value:
raise ValueError("ai.prompt_dir is required")
prompt_dir = Path(prompt_dir_value).expanduser()
def parse_provider(
name: str,
*,
default_url: str,
default_model: str,
env_prefix: str,
) -> AiProviderConfig:
provider = _parse_mapping(raw_config.get(name), field_name=f"ai.{name}")
api_url = str(provider.get("api_url", default_url)).strip()
raw_api_key = provider.get("api_key")
api_key = str(raw_api_key).strip() if raw_api_key is not None else None
api_key = api_key or None
model = str(provider.get("model", default_model)).strip()
if os.environ.get(f"{env_prefix}_API_URL") is not None:
api_url = os.environ[f"{env_prefix}_API_URL"].strip()
if os.environ.get(f"{env_prefix}_API_KEY") is not None:
api_key = os.environ[f"{env_prefix}_API_KEY"].strip() or None
if os.environ.get(f"{env_prefix}_MODEL") is not None:
model = os.environ[f"{env_prefix}_MODEL"].strip()
if not api_url:
raise ValueError(f"ai.{name}.api_url is required")
if not model:
raise ValueError(f"ai.{name}.model is required")
return AiProviderConfig(api_url=api_url, api_key=api_key, model=model)
glm = parse_provider(
"glm",
default_url="https://api.z.ai/api/paas/v4",
default_model="glm-5.3",
env_prefix="GLM",
)
deepseek = parse_provider(
"deepseek",
default_url="https://api.deepseek.com",
default_model="deepseek-v4-flash",
env_prefix="DEEPSEEK",
)
claude = parse_provider(
"claude",
default_url="https://api.anthropic.com",
default_model="claude-haiku-5-5",
env_prefix="CLAUDE",
)
openrouter = parse_provider(
"openrouter",
default_url="https://openrouter.ai/api/v1",
default_model="anthropic/claude-haiku-5.5",
env_prefix="OPENROUTER",
)
max_context_messages = _parse_int(
raw_config.get("max_context_messages", 100),
field_name="ai.max_context_messages",
)
max_media_bytes = _parse_int(
raw_config.get("max_media_bytes", 5 * 1024 * 1024),
field_name="ai.max_media_bytes",
)
max_tool_rounds = _parse_int(
raw_config.get("max_tool_rounds", 16),
field_name="ai.max_tool_rounds",
)
brave_timeout_seconds = _parse_float(
raw_config.get("brave_timeout_seconds", 10),
field_name="ai.brave_timeout_seconds",
)
web_page_timeout_seconds = _parse_float(
raw_config.get("web_page_timeout_seconds", 45),
field_name="ai.web_page_timeout_seconds",
)
web_page_max_chars = _parse_int(
raw_config.get("web_page_max_chars", 60_000),
field_name="ai.web_page_max_chars",
)
raw_brave_key = raw_config.get("brave_api_key")
brave_api_key = str(raw_brave_key).strip() if raw_brave_key is not None else None
brave_api_key = brave_api_key or None
if os.environ.get("BRAVE_SEARCH_API_KEY") is not None:
brave_api_key = os.environ["BRAVE_SEARCH_API_KEY"].strip() or None
if os.environ.get("AI_MAX_CONTEXT_MESSAGES") is not None:
max_context_messages = _parse_int(
os.environ["AI_MAX_CONTEXT_MESSAGES"],
field_name="AI_MAX_CONTEXT_MESSAGES",
)
if os.environ.get("AI_MAX_MEDIA_BYTES") is not None:
max_media_bytes = _parse_int(
os.environ["AI_MAX_MEDIA_BYTES"],
field_name="AI_MAX_MEDIA_BYTES",
)
if os.environ.get("AI_MAX_TOOL_ROUNDS") is not None:
max_tool_rounds = _parse_int(
os.environ["AI_MAX_TOOL_ROUNDS"],
field_name="AI_MAX_TOOL_ROUNDS",
)
if os.environ.get("BRAVE_SEARCH_TIMEOUT_SECONDS") is not None:
brave_timeout_seconds = _parse_float(
os.environ["BRAVE_SEARCH_TIMEOUT_SECONDS"],
field_name="BRAVE_SEARCH_TIMEOUT_SECONDS",
)
if os.environ.get("AI_WEB_PAGE_TIMEOUT_SECONDS") is not None:
web_page_timeout_seconds = _parse_float(
os.environ["AI_WEB_PAGE_TIMEOUT_SECONDS"],
field_name="AI_WEB_PAGE_TIMEOUT_SECONDS",
)
if os.environ.get("AI_WEB_PAGE_MAX_CHARS") is not None:
web_page_max_chars = _parse_int(
os.environ["AI_WEB_PAGE_MAX_CHARS"],
field_name="AI_WEB_PAGE_MAX_CHARS",
)
if max_context_messages < 0:
raise ValueError("ai.max_context_messages must not be negative")
if max_media_bytes < 1:
raise ValueError("ai.max_media_bytes must be greater than zero")
if max_tool_rounds < 1:
raise ValueError("ai.max_tool_rounds must be greater than zero")
if brave_timeout_seconds <= 0:
raise ValueError("ai.brave_timeout_seconds must be greater than zero")
if web_page_timeout_seconds <= 0:
raise ValueError("ai.web_page_timeout_seconds must be greater than zero")
if web_page_max_chars < 1:
raise ValueError("ai.web_page_max_chars must be greater than zero")
return AiConfig(
glm=glm,
deepseek=deepseek,
claude=claude,
openrouter=openrouter,
prompt_dir=prompt_dir,
max_context_messages=max_context_messages,
max_media_bytes=max_media_bytes,
max_tool_rounds=max_tool_rounds,
brave_api_key=brave_api_key,
brave_timeout_seconds=brave_timeout_seconds,
web_page_timeout_seconds=web_page_timeout_seconds,
web_page_max_chars=web_page_max_chars,
)
def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_api_hash: str | None, default_session_dir: Path) -> list[AccountConfig]:
if raw_accounts is None:
return []
if not isinstance(raw_accounts, list):
raise ValueError("accounts must be a list")
accounts: list[AccountConfig] = []
for index, raw_account in enumerate(raw_accounts, start=1):
if isinstance(raw_account, str):
raw_account = {"name": raw_account, "session": raw_account}
if not isinstance(raw_account, dict):
raise ValueError(f"accounts[{index}] must be a string or object")
name = str(
raw_account.get("name")
or raw_account.get("label")
or raw_account.get("session")
or raw_account.get("session_name")
or ""
).strip()
session = str(raw_account.get("session") or raw_account.get("session_name") or name).strip()
if not name:
raise ValueError(f"accounts[{index}] is missing name")
if not session:
raise ValueError(f"accounts[{index}] is missing session")
account_api_id = raw_account.get("api_id", default_api_id)
account_api_hash = raw_account.get("api_hash", default_api_hash)
if account_api_id is not None:
account_api_id = _parse_int(account_api_id, field_name=f"accounts[{index}].api_id")
if account_api_hash is not None:
account_api_hash = str(account_api_hash)
account_workdir = raw_account.get("workdir")
if account_workdir is not None:
account_workdir = Path(str(account_workdir)).expanduser()
if not account_workdir.is_absolute():
account_workdir = (default_session_dir.parent / account_workdir).resolve()
accounts.append(
AccountConfig(
name=name,
session=session,
api_id=account_api_id,
api_hash=account_api_hash,
session_string=raw_account.get("session_string"),
phone_number=raw_account.get("phone_number"),
password=raw_account.get("password"),
phone_code=raw_account.get("phone_code"),
bot_token=raw_account.get("bot_token"),
proxy=raw_account.get("proxy"),
workdir=account_workdir,
in_memory=bool(raw_account.get("in_memory", False) or raw_account.get("session_string")),
no_updates=bool(raw_account.get("no_updates", False)),
takeout=bool(raw_account.get("takeout", False)),
hide_password=bool(raw_account.get("hide_password", False)),
use_qr=_parse_bool(
raw_account.get("use_qr", False),
field_name=f"accounts[{index}].use_qr",
),
)
)
return accounts
def _accounts_from_env() -> list[dict[str, Any]] | None:
raw_accounts = os.environ.get("ACCOUNTS_JSON")
if raw_accounts:
parsed = json.loads(raw_accounts)
if not isinstance(parsed, list):
raise ValueError("ACCOUNTS_JSON must contain a JSON list")
return parsed
raw_sessions = os.environ.get("ACCOUNT_SESSIONS")
if raw_sessions:
sessions = [item.strip() for item in raw_sessions.split(",") if item.strip()]
return [{"name": session, "session": session} for session in sessions]
return None
def load_app_config(root: Path) -> AppConfig:
dotenv_path = os.environ.get("BOT_DOTENV_PATH")
if dotenv_path:
candidate_dotenv = Path(dotenv_path).expanduser()
if not candidate_dotenv.is_absolute():
candidate_dotenv = (root / candidate_dotenv).resolve()
else:
candidate_dotenv = root / ".env"
load_dotenv(candidate_dotenv, override=False)
config_path = os.environ.get("BOT_CONFIG")
if config_path:
candidate = Path(config_path).expanduser()
if not candidate.is_absolute():
candidate = (root / candidate).resolve()
else:
candidate = root / "config.toml"
raw: dict[str, Any] = {}
if candidate.exists():
raw = _read_toml_file(candidate)
env_api_id = os.environ.get("TG_API_ID")
env_api_hash = os.environ.get("TG_API_HASH")
env_session_dir = os.environ.get("SESSION_DIR")
if env_api_id is not None:
raw["api_id"] = env_api_id
if env_api_hash is not None:
raw["api_hash"] = env_api_hash
if env_session_dir is not None:
raw["session_dir"] = env_session_dir
env_accounts = _accounts_from_env()
if env_accounts is not None:
raw["accounts"] = env_accounts
if "accounts" not in raw:
raise ValueError(
"Missing accounts configuration. Provide accounts in config.toml or set ACCOUNTS_JSON / ACCOUNT_SESSIONS."
)
api_id = _parse_int(raw["api_id"], field_name="api_id") if "api_id" in raw else None
api_hash = str(raw["api_hash"]) if "api_hash" in raw else None
session_dir = Path(str(raw.get("session_dir", "sessions"))).expanduser()
if not session_dir.is_absolute():
session_dir = (root / session_dir).resolve()
accounts = _parse_accounts(
raw["accounts"],
default_api_id=api_id,
default_api_hash=api_hash,
default_session_dir=session_dir,
)
elevenlabs = _parse_elevenlabs_config(raw.get("elevenlabs"))
ai = _parse_ai_config(raw.get("ai"))
if not ai.prompt_dir.is_absolute():
ai.prompt_dir = root / ai.prompt_dir
ai.prompt_dir = ai.prompt_dir.resolve()
ai.base_dir = root
if not accounts:
raise ValueError("At least one account must be configured")
seen_names: set[str] = set()
seen_sessions: set[str] = set()
for account in accounts:
if account.name in seen_names:
raise ValueError(f"Duplicate account name: {account.name}")
if account.session in seen_sessions:
raise ValueError(f"Duplicate session name: {account.session}")
seen_names.add(account.name)
seen_sessions.add(account.session)
return AppConfig(
api_id=api_id,
api_hash=api_hash,
session_dir=session_dir,
accounts=accounts,
elevenlabs=elevenlabs,
ai=ai,
)