feat: rebuild AI commands and migrate config to TOML

This commit is contained in:
Azimkin
2026-09-14 13:03:04 +02:00
parent 01d3173254
commit 4421c9cd31
53 changed files with 1840 additions and 3464 deletions

View File

@@ -1,15 +1,12 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
try:
from dotenv import load_dotenv
except ModuleNotFoundError: # pragma: no cover - optional dependency fallback
@@ -36,71 +33,6 @@ class AccountConfig:
hide_password: bool = False
@dataclass(slots=True)
class AIProviderConfig:
name: str
api_key: str
base_url: str
timeout: float | None = None
@dataclass(slots=True)
class AIModelConfig:
alias: str
provider: str
model: str
description: str | None = None
@dataclass(slots=True)
class AIPromptConfig:
root: Path
system_file: str = "system.md"
parts_dir: str = "parts"
user_template: str | None = None
@dataclass(slots=True)
class AIWebSearchConfig:
enabled: bool = True
api_key: str | None = None
endpoint: str = "https://api.search.brave.com/res/v1/web/search"
count: int = 5
country: str = "US"
search_lang: str = "en"
ui_lang: str = "en-US"
safesearch: str = "moderate"
timeout: float = 10.0
extra_snippets: bool = False
@property
def available(self) -> bool:
return self.enabled and bool(self.api_key)
@dataclass(slots=True)
class AIPageExtractConfig:
enabled: bool = True
timeout: float = 15.0
max_chars: int = 12000
render_dynamic: bool = True
dynamic_wait_ms: int = 1500
block_private_hosts: bool = True
@dataclass(slots=True)
class AIConfig:
default_model: str
context_messages: int
prompt: AIPromptConfig
providers: dict[str, AIProviderConfig] = field(default_factory=dict)
models: dict[str, AIModelConfig] = field(default_factory=dict)
workspace_dir: Path = field(default_factory=lambda: Path("workspace"))
tool_max_rounds: int = 10
web_search: AIWebSearchConfig = field(default_factory=AIWebSearchConfig)
page_extract: AIPageExtractConfig = field(default_factory=AIPageExtractConfig)
@dataclass(slots=True)
class GitHubWebhookConfig:
enabled: bool = False
@@ -109,47 +41,58 @@ class GitHubWebhookConfig:
secret: str | None = None
@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",
)
)
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
@dataclass(slots=True)
class AppConfig:
api_id: int | None
api_hash: str | None
session_dir: Path
accounts: list[AccountConfig]
ai: AIConfig | None = None
github_webhook: GitHubWebhookConfig = field(default_factory=GitHubWebhookConfig)
elevenlabs: ElevenLabsConfig = field(default_factory=ElevenLabsConfig)
ai: AiConfig = field(default_factory=AiConfig)
def _read_json_file(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _write_json_file(path: Path, data: dict[str, Any]) -> None:
serialized = json.dumps(data, ensure_ascii=False, indent=2)
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=path.parent,
prefix=path.name,
suffix=".tmp",
) as handle:
handle.write(serialized)
temp_path = Path(handle.name)
temp_path.replace(path)
def _merge_example_defaults(target: dict[str, Any], source: dict[str, Any], path: str = "") -> list[str]:
"""Add scalar keys present in source but absent in target; recurse into existing dicts."""
added: list[str] = []
for key, value in source.items():
full_path = f"{path}.{key}" if path else key
if key not in target:
if not isinstance(value, (dict, list)):
target[key] = value
added.append(full_path)
elif isinstance(target[key], dict) and isinstance(value, dict):
added.extend(_merge_example_defaults(target[key], value, full_path))
return added
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:
@@ -159,13 +102,6 @@ def _parse_int(value: Any, *, field_name: str) -> int:
raise ValueError(f"{field_name} must be an integer") from exc
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_bool(value: Any, *, field_name: str) -> bool:
if isinstance(value, bool):
return value
@@ -180,6 +116,13 @@ def _parse_bool(value: Any, *, field_name: str) -> bool:
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 {}
@@ -188,125 +131,6 @@ def _parse_mapping(value: Any, *, field_name: str) -> dict[str, Any]:
return value
def _normalize_brave_api_key(value: Any) -> str | None:
api_key = str(value or "").strip()
if api_key.lower().startswith("bearer "):
api_key = api_key[7:].strip()
return api_key or None
def _parse_web_search_config(raw_ai: dict[str, Any]) -> AIWebSearchConfig:
raw = _parse_mapping(raw_ai.get("web_search"), field_name="ai.web_search")
enabled = _parse_bool(raw.get("enabled", True), field_name="ai.web_search.enabled")
api_key = _normalize_brave_api_key(raw.get("api_key"))
endpoint = str(
raw.get("endpoint") or "https://api.search.brave.com/res/v1/web/search"
).strip()
if not endpoint:
raise ValueError("ai.web_search.endpoint is required")
count = _parse_int(raw.get("count", 5), field_name="ai.web_search.count")
count = min(max(count, 1), 20)
country = str(raw.get("country", "US") or "US").strip().upper()
search_lang = str(raw.get("search_lang", "en") or "en").strip().lower()
ui_lang = str(raw.get("ui_lang", "en-US") or "en-US").strip()
safesearch = str(raw.get("safesearch", "moderate") or "moderate").strip().lower()
if safesearch not in {"off", "moderate", "strict"}:
raise ValueError("ai.web_search.safesearch must be one of: off, moderate, strict")
timeout = _parse_float(raw.get("timeout", 10.0), field_name="ai.web_search.timeout")
if timeout <= 0:
raise ValueError("ai.web_search.timeout must be > 0")
extra_snippets = _parse_bool(
raw.get("extra_snippets", False),
field_name="ai.web_search.extra_snippets",
)
env_search_api_key = os.environ.get("BRAVE_SEARCH_API_KEY")
if env_search_api_key is not None:
api_key = _normalize_brave_api_key(env_search_api_key)
env_api_key = os.environ.get("BRAVE_API_KEY")
if env_api_key is not None:
api_key = _normalize_brave_api_key(env_api_key)
if os.environ.get("BRAVE_SEARCH_COUNT") is not None:
count = min(
max(_parse_int(os.environ["BRAVE_SEARCH_COUNT"], field_name="BRAVE_SEARCH_COUNT"), 1),
20,
)
if os.environ.get("BRAVE_SEARCH_COUNTRY") is not None:
country = os.environ["BRAVE_SEARCH_COUNTRY"].strip().upper() or country
if os.environ.get("BRAVE_SEARCH_LANG") is not None:
search_lang = os.environ["BRAVE_SEARCH_LANG"].strip().lower() or search_lang
if os.environ.get("BRAVE_SEARCH_UI_LANG") is not None:
ui_lang = os.environ["BRAVE_SEARCH_UI_LANG"].strip() or ui_lang
if os.environ.get("BRAVE_SEARCH_SAFESEARCH") is not None:
safesearch = os.environ["BRAVE_SEARCH_SAFESEARCH"].strip().lower()
if safesearch not in {"off", "moderate", "strict"}:
raise ValueError("BRAVE_SEARCH_SAFESEARCH must be one of: off, moderate, strict")
return AIWebSearchConfig(
enabled=enabled,
api_key=api_key,
endpoint=endpoint,
count=count,
country=country,
search_lang=search_lang,
ui_lang=ui_lang,
safesearch=safesearch,
timeout=timeout,
extra_snippets=extra_snippets,
)
def _parse_page_extract_config(raw_ai: dict[str, Any]) -> AIPageExtractConfig:
raw = _parse_mapping(raw_ai.get("page_extract"), field_name="ai.page_extract")
enabled = _parse_bool(raw.get("enabled", True), field_name="ai.page_extract.enabled")
timeout = _parse_float(raw.get("timeout", 15.0), field_name="ai.page_extract.timeout")
if timeout <= 0:
raise ValueError("ai.page_extract.timeout must be > 0")
max_chars = _parse_int(raw.get("max_chars", 12000), field_name="ai.page_extract.max_chars")
if max_chars < 1000:
raise ValueError("ai.page_extract.max_chars must be >= 1000")
render_dynamic = _parse_bool(
raw.get("render_dynamic", True),
field_name="ai.page_extract.render_dynamic",
)
dynamic_wait_ms = _parse_int(
raw.get("dynamic_wait_ms", 1500),
field_name="ai.page_extract.dynamic_wait_ms",
)
dynamic_wait_ms = min(max(dynamic_wait_ms, 0), 10000)
block_private_hosts = _parse_bool(
raw.get("block_private_hosts", True),
field_name="ai.page_extract.block_private_hosts",
)
if os.environ.get("PAGE_EXTRACT_MAX_CHARS") is not None:
max_chars = _parse_int(os.environ["PAGE_EXTRACT_MAX_CHARS"], field_name="PAGE_EXTRACT_MAX_CHARS")
if max_chars < 1000:
raise ValueError("PAGE_EXTRACT_MAX_CHARS must be >= 1000")
if os.environ.get("PAGE_EXTRACT_RENDER_DYNAMIC") is not None:
render_dynamic = _parse_bool(
os.environ["PAGE_EXTRACT_RENDER_DYNAMIC"],
field_name="PAGE_EXTRACT_RENDER_DYNAMIC",
)
return AIPageExtractConfig(
enabled=enabled,
timeout=timeout,
max_chars=max_chars,
render_dynamic=render_dynamic,
dynamic_wait_ms=dynamic_wait_ms,
block_private_hosts=block_private_hosts,
)
def _parse_github_webhook_config(raw: Any) -> GitHubWebhookConfig:
raw_config = _parse_mapping(raw, field_name="github_webhook")
@@ -353,6 +177,153 @@ def _parse_github_webhook_config(raw: Any) -> GitHubWebhookConfig:
)
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")
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",
)
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",
)
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 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")
return AiConfig(
glm=glm,
deepseek=deepseek,
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,
)
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 []
@@ -418,109 +389,6 @@ def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_ap
return accounts
def _parse_ai_config(raw_ai: Any, *, root: Path) -> AIConfig | None:
if raw_ai is None:
return None
if not isinstance(raw_ai, dict):
raise ValueError("ai must be an object")
prompt_raw = _parse_mapping(raw_ai.get("prompt"), field_name="ai.prompt")
prompt_root = Path(str(prompt_raw.get("root", "prompts/ai"))).expanduser()
if not prompt_root.is_absolute():
prompt_root = (root / prompt_root).resolve()
raw_user_template = prompt_raw.get("user_template")
user_template = str(raw_user_template).strip() if raw_user_template else None
prompt = AIPromptConfig(
root=prompt_root,
system_file=str(prompt_raw.get("system_file", "system.md")),
parts_dir=str(prompt_raw.get("parts_dir", "parts")),
user_template=user_template or None,
)
default_model = str(raw_ai.get("default_model", "")).strip()
if not default_model:
raise ValueError("ai.default_model is required")
context_messages = _parse_int(raw_ai.get("context_messages", 12), field_name="ai.context_messages")
if context_messages < 0:
raise ValueError("ai.context_messages must be >= 0")
providers_raw = _parse_mapping(raw_ai.get("providers"), field_name="ai.providers")
models_raw = _parse_mapping(raw_ai.get("models"), field_name="ai.models")
providers: dict[str, AIProviderConfig] = {}
for provider_name, provider_config in providers_raw.items():
if not isinstance(provider_config, dict):
raise ValueError(f"ai.providers.{provider_name} must be an object")
api_key = str(provider_config.get("api_key", "")).strip()
base_url = str(provider_config.get("base_url", "")).strip()
if not api_key:
raise ValueError(f"ai.providers.{provider_name}.api_key is required")
if not base_url:
raise ValueError(f"ai.providers.{provider_name}.base_url is required")
timeout_value = provider_config.get("timeout")
timeout = None
if timeout_value is not None:
timeout = _parse_float(timeout_value, field_name=f"ai.providers.{provider_name}.timeout")
providers[provider_name] = AIProviderConfig(
name=provider_name,
api_key=api_key,
base_url=base_url,
timeout=timeout,
)
models: dict[str, AIModelConfig] = {}
for alias, model_config in models_raw.items():
if not isinstance(model_config, dict):
raise ValueError(f"ai.models.{alias} must be an object")
provider = str(model_config.get("provider", "")).strip()
model = str(model_config.get("model", "")).strip()
if not provider:
raise ValueError(f"ai.models.{alias}.provider is required")
if not model:
raise ValueError(f"ai.models.{alias}.model is required")
description = model_config.get("description")
models[alias] = AIModelConfig(
alias=alias,
provider=provider,
model=model,
description=str(description).strip() if description is not None else None,
)
if default_model not in models:
raise ValueError("ai.default_model must reference one of ai.models")
for alias, model_config in models.items():
if model_config.provider not in providers:
raise ValueError(
f"ai.models.{alias}.provider references unknown provider: {model_config.provider}"
)
workspace_dir_raw = raw_ai.get("workspace_dir", "workspace")
workspace_dir = Path(str(workspace_dir_raw)).expanduser()
if not workspace_dir.is_absolute():
workspace_dir = (root / workspace_dir).resolve()
tool_max_rounds = _parse_int(raw_ai.get("tool_max_rounds", 10), field_name="ai.tool_max_rounds")
if tool_max_rounds < 1:
raise ValueError("ai.tool_max_rounds must be >= 1")
web_search = _parse_web_search_config(raw_ai)
page_extract = _parse_page_extract_config(raw_ai)
return AIConfig(
default_model=default_model,
context_messages=context_messages,
prompt=prompt,
providers=providers,
models=models,
workspace_dir=workspace_dir,
tool_max_rounds=tool_max_rounds,
web_search=web_search,
page_extract=page_extract,
)
def _accounts_from_env() -> list[dict[str, Any]] | None:
raw_accounts = os.environ.get("ACCOUNTS_JSON")
if raw_accounts:
@@ -554,26 +422,15 @@ def load_app_config(root: Path) -> AppConfig:
if not candidate.is_absolute():
candidate = (root / candidate).resolve()
else:
candidate = root / "config.json"
candidate = root / "config.toml"
raw: dict[str, Any] = {}
if candidate.exists():
raw = _read_json_file(candidate)
example_path = root / "config.example.json"
if example_path.exists():
try:
example_raw = _read_json_file(example_path)
added = _merge_example_defaults(raw, example_raw)
if added:
_write_json_file(candidate, raw)
log.info("Auto-updated %s with new fields: %s", candidate.name, ", ".join(added))
except Exception as exc:
log.warning("Could not auto-update config from example: %s", exc)
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")
env_ai_config = os.environ.get("AI_CONFIG")
if env_api_id is not None:
raw["api_id"] = env_api_id
@@ -581,8 +438,6 @@ def load_app_config(root: Path) -> AppConfig:
raw["api_hash"] = env_api_hash
if env_session_dir is not None:
raw["session_dir"] = env_session_dir
if env_ai_config:
raw["ai"] = json.loads(env_ai_config)
env_accounts = _accounts_from_env()
if env_accounts is not None:
@@ -590,7 +445,7 @@ def load_app_config(root: Path) -> AppConfig:
if "accounts" not in raw:
raise ValueError(
"Missing accounts configuration. Provide accounts in config.json or set ACCOUNTS_JSON / ACCOUNT_SESSIONS."
"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
@@ -606,8 +461,11 @@ def load_app_config(root: Path) -> AppConfig:
default_session_dir=session_dir,
)
ai = _parse_ai_config(raw.get("ai"), root=root)
github_webhook = _parse_github_webhook_config(raw.get("github_webhook"))
elevenlabs = _parse_elevenlabs_config(raw.get("elevenlabs"))
ai = _parse_ai_config(raw.get("ai"))
ai.prompt_dir = root / "prompts"
ai.base_dir = root
if not accounts:
raise ValueError("At least one account must be configured")
@@ -627,6 +485,7 @@ def load_app_config(root: Path) -> AppConfig:
api_hash=api_hash,
session_dir=session_dir,
accounts=accounts,
ai=ai,
github_webhook=github_webhook,
elevenlabs=elevenlabs,
ai=ai,
)