"""Настройки, AI-провайдеры, курсы валют (роутер /api).""" from __future__ import annotations import asyncio import json import httpx from fastapi import APIRouter, Depends, HTTPException from .. import constants as C from ..auth import current_login from ..crypto import decrypt_text, encrypt_text from ..db import store from ..services import ai as ai_svc from ..services import rates as rates_svc from ..services.telegram import tg router = APIRouter(prefix="/api", tags=["settings"]) # Какие настройки видны наружу (без секретов). Значения секретов маскируются. _PUBLIC_INT = {"archiveAfterDays", "archiveClearDays", "trashClearDays", "minLen", "discJoinLimit", "discJoinDelayMin", "discJoinDelayMax", "discEvalSample", "discEvalThreshold"} _PUBLIC_BOOL = {"autoArchive", "aiFilterEnabled", "aiEnabled", "conversionOn", "remindersEnabled", "mlEnabled", "blockResumes", "budgetRequiredHire", "budgetRequiredOrder", "autoMonitorNew", "discPaused"} _PUBLIC_STR = {"targetCurrency", "rateSource", "aiProvider", "aiPrompt", "aiFilterPrompt", "cardPrompt", "domainDescription", "wantedType", "hireLabel", "orderLabel"} _PUBLIC_LIST = {"stopPhrases", "domainKeywords", "hireMarkers", "levelTerms", "resumeMarkers"} _PUBLIC_DICT = {"colState"} def mask(value: str) -> str: if not value: return "" return value if len(value) <= 8 else f"{value[:4]}…{value[-4:]}" def public_settings() -> dict: out: dict = {} allset = store.all_settings() for k in _PUBLIC_INT: out[k] = int(allset.get(k, 0)) for k in _PUBLIC_BOOL: out[k] = bool(allset.get(k)) for k in _PUBLIC_STR: out[k] = allset.get(k, "") for k in _PUBLIC_LIST: out[k] = allset.get(k, []) for k in _PUBLIC_DICT: out[k] = allset.get(k, {}) out["myPrompts"] = allset.get("myPrompts", []) tg_keys = store.get_setting("tgKeys") or {} api_hash = str(tg_keys.get("apiHash", "")) out["tgKeys"] = { "apiId": mask(str(tg_keys.get("apiId", ""))), "apiHashSet": bool(decrypt_text(api_hash)) if api_hash.startswith("enc:") else bool(api_hash), } cfg = store.get_setting("aiConfigs") or {} out["aiConfigs"] = {} for pid, conf in cfg.items(): raw_key = str(conf.get("apiKey", "")) plain_key = decrypt_text(raw_key) if raw_key.startswith("enc:") else raw_key out["aiConfigs"][pid] = { "baseUrl": conf.get("baseUrl", ""), "model": conf.get("model", ""), "keySet": bool(plain_key), "keyMasked": mask(plain_key), } out["providers"] = C.AI_PROVIDERS return out @router.get("/settings") def get_settings(_: str = Depends(current_login)) -> dict: return public_settings() @router.patch("/settings") async def patch_settings(body: dict, _: str = Depends(current_login)) -> dict: current = store.all_settings() # инвариант пауз авто-вступлений: если пришли оба конца интервала — клампы # (5..600) и min <= max (если нет — меняем местами, как делает фронт); если # пришёл один конец — клампим его относительно сохранённого другого конца delay_min, delay_max = body.get("discJoinDelayMin"), body.get("discJoinDelayMax") if delay_min is not None and delay_max is not None: try: dmin, dmax = int(delay_min), int(delay_max) except (TypeError, ValueError): pass else: dmin = max(5, min(600, dmin)) dmax = max(5, min(600, dmax)) if dmin > dmax: dmin, dmax = dmax, dmin body["discJoinDelayMin"] = dmin body["discJoinDelayMax"] = dmax elif delay_min is not None: try: cur_max = int(current.get("discJoinDelayMax") or 0) value = max(5, min(600, int(delay_min))) except (TypeError, ValueError): pass else: body["discJoinDelayMin"] = min(value, cur_max) if cur_max >= 5 else value elif delay_max is not None: try: cur_min = int(current.get("discJoinDelayMin") or 0) value = max(5, min(600, int(delay_max))) except (TypeError, ValueError): pass else: body["discJoinDelayMax"] = max(value, cur_min) if cur_min <= 600 else value for key, value in body.items(): if key in _PUBLIC_INT: try: value = int(value) except (TypeError, ValueError): continue if key == "archiveAfterDays": value = max(1, min(30, value)) if key == "minLen": value = max(10, min(500, value)) if key == "discJoinLimit": value = max(1, min(200, value)) if key in {"discJoinDelayMin", "discJoinDelayMax"}: value = max(5, min(600, value)) if key == "discEvalSample": value = max(3, min(30, value)) if key == "discEvalThreshold": value = max(1, min(100, value)) store.set_setting(key, value) elif key in _PUBLIC_BOOL: store.set_setting(key, bool(value)) elif key in _PUBLIC_STR: if key in {"targetCurrency"}: value = str(value).upper() if key == "aiProvider" and not any(p["id"] == value for p in C.AI_PROVIDERS): continue store.set_setting(key, str(value)) elif key in _PUBLIC_LIST: if isinstance(value, list): store.set_setting(key, [str(x) for x in value][:200]) elif key in _PUBLIC_DICT: if isinstance(value, dict): store.set_setting(key, value) elif key == "myPrompts" and isinstance(value, list): clean: list[dict] = [] for item in value[:100]: if not isinstance(item, dict): continue name = str(item.get("name") or "").strip()[:80] prompt = str(item.get("prompt") or "").strip()[:8000] if not name or not prompt: continue clean.append( { "id": str(item.get("id") or "")[:40] or store.uid("pp_"), "name": name, "description": str(item.get("description") or "").strip()[:300], "prompt": prompt, } ) store.set_setting("myPrompts", clean) elif key == "aiConfigs" and isinstance(value, dict): cfg = current.get("aiConfigs") or {} for pid, conf in value.items(): if pid not in cfg: continue entry = dict(cfg[pid]) if isinstance(conf, dict): for fk in ("baseUrl", "model"): if fk in conf and conf[fk] is not None: entry[fk] = str(conf[fk]) new_key = conf.get("apiKey") if new_key and not str(new_key).startswith(("enc:",)) and len(str(new_key)) >= 8: entry["apiKey"] = encrypt_text(str(new_key)) cfg[pid] = entry store.set_setting("aiConfigs", cfg) elif key == "tgKeys" and isinstance(value, dict): keys = current.get("tgKeys") or {} if value.get("apiId") is not None: api = str(value["apiId"]).strip() if api.isdigit() and 5 < len(api) < 10: keys["apiId"] = api new_hash = value.get("apiHash") if new_hash and len(str(new_hash)) >= 16 and not str(new_hash).startswith("enc:"): keys["apiHash"] = encrypt_text(str(new_hash).strip()) store.set_setting("tgKeys", keys) # смена источника курсов — обновляем в фоне; смена целевой валюты/конвертации — # пересчёт старых карточек (кроме архива/корзины) if body.get("rateSource"): asyncio.get_running_loop().create_task(rates_svc.refresh_rates()) if body.get("targetCurrency") is not None or body.get("conversionOn") is not None: rates_svc.recompute_conversions() return public_settings() @router.post("/ai/check") async def ai_check(_: str = Depends(current_login)) -> dict: status = ai_svc.provider_status() provider_id, data = ai_svc._cfg() meta = data["meta"] if meta.get("local"): return {**status, "ok": True, "message": f"Локальный сервер «{meta['name']}» (ping в проде)"} key = data["cfg"].get("apiKey") if not key: return {**status, "ok": False, "message": "Не задан API-ключ"} # Лёгкая проверка: GET /models (для OpenAI-совместимых) или /v1/models (Anthropic) base = str(data["cfg"].get("baseUrl") or meta["base"]).rstrip("/") url = base + ("/v1/models" if meta.get("api_style") == "anthropic" else "/models") headers = {"Authorization": f"Bearer {key}"} if meta.get("api_style") != "anthropic" else { "x-api-key": key, "anthropic-version": "2023-06-01"} try: async with httpx.AsyncClient(timeout=12) as client: resp = await client.get(url, headers=headers) if resp.status_code < 400: return {**status, "ok": True, "message": "Подключение успешно"} if resp.status_code in (401, 403): return {**status, "ok": False, "message": f"Ключ не принят (HTTP {resp.status_code}) — проверьте ключ и доступ к модели"} return {**status, "ok": False, "message": f"HTTP {resp.status_code} — проверьте Base URL и модель"} except Exception as exc: # noqa: BLE001 return {**status, "ok": False, "message": f"Ошибка соединения: {exc}"} # ─── Курсы ──────────────────────────────────────────────────────────────── @router.get("/rates") def get_rates(_: str = Depends(current_login)) -> dict: return rates_svc.get_rates() @router.post("/rates/refresh") async def refresh_rates(_: str = Depends(current_login)) -> dict: ok = await rates_svc.refresh_rates() return {"ok": ok, "rates": rates_svc.get_rates()} # ─── Методанные для фронта ──────────────────────────────────────────────── @router.get("/meta/constants") def meta_constants(_: str = Depends(current_login)) -> dict: return { "currencies": C.CURRENCIES, "stages": C.PIPELINE_STAGES, "palette": C.PALETTE, }