Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -0,0 +1 @@
"""Пакет роутеров API."""
@@ -0,0 +1,65 @@
"""Вход в дашборд и сессии (п.4.1 ТЗ)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from ..auth import (
change_password,
create_session,
current_login,
destroy_session,
ensure_creds,
set_session_cookie,
verify_password,
)
from ..config import COOKIE_NAME
router = APIRouter(prefix="/api", tags=["auth"])
class LoginBody(BaseModel):
login: str
password: str
class PasswordBody(BaseModel):
oldPassword: str
newPassword: str
@router.post("/auth/login")
def login(body: LoginBody, response: Response) -> dict:
ensure_creds()
login_name = body.login.strip()
if not verify_password(login_name, body.password):
raise HTTPException(401, "Неверный логин или пароль")
token = create_session(login_name)
set_session_cookie(response, token)
return {"ok": True, "login": login_name}
@router.post("/auth/logout")
def logout(request: Request, response: Response) -> dict:
token = request.cookies.get(COOKIE_NAME)
destroy_session(token)
response.delete_cookie(COOKIE_NAME)
return {"ok": True}
@router.get("/auth/me")
def me(login: str = Depends(current_login)) -> dict:
return {"login": login, "ok": True}
@router.post("/auth/change-password")
def change(body: PasswordBody, request: Request, response: Response, login: str = Depends(current_login)) -> dict:
token = request.cookies.get(COOKIE_NAME)
ok = change_password(login, body.oldPassword, body.newPassword)
if not ok:
raise HTTPException(400, "Текущий пароль неверен")
# старые сессии удалены внутри change_password; выдаём свежую
fresh = create_session(login)
destroy_session(token)
set_session_cookie(response, fresh)
return {"ok": True}
@@ -0,0 +1,409 @@
"""Дашборд: доски, колонки, карточки, поиск, правила хранения."""
from __future__ import annotations
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .. import config
from .. import constants as C
from ..auth import current_login
from ..db import store
from ..sse import broker
from ..services import fts as fts_svc
from ..services import leads as leads_svc
from ..services.ai import normalize_dedup
from ..services.pipeline import _store_lead, lead_to_dict, stage1_plain
from ..services.rates import refresh_rates # noqa: F401 (для админ-тика может пригодиться)
router = APIRouter(prefix="/api", tags=["dashboard"])
class BoardCreate(BaseModel):
name: str
description: str = ""
color: str | None = None
keywords: list[str] | None = None
prompt: str = ""
rules: dict | None = None
class BoardPatch(BaseModel):
name: str | None = None
description: str | None = None
color: str | None = None
width: str | None = None
collapsed: bool | None = None
prompt: str | None = None
keywords: list[str] | None = None
visibleFields: list[str] | None = None
suggested: bool | None = None
rules: dict | None = None
note: str | None = None
class OrderBody(BaseModel):
order: list[str]
class ColStateBody(BaseModel):
collapsed: bool | None = None
width: str | None = None
class MoveBody(BaseModel):
to: str
class CommentBody(BaseModel):
text: str
class ReclassifyBody(BaseModel):
ids: list[str] | None = None
class PumpGateBody(BaseModel):
limit: int | None = None # 0/None — выключить; >0 — остановить воркер после N созданных карточек
class CheckMessageBody(BaseModel):
text: str
# Демо-пул для кнопки «Симулировать лид» (без обращения к ИИ)
_DEMO_POOL = [
("Нужен Middle Python-разработчик на бота для CRM, удалённо, 1 6002 200$.",
{"title": "Middle Python-разработчик на бота для CRM", "summary": "Развитие CRM: бот для менеджеров, интеграция с amoCRM.",
"stack": ["Python", "aiogram", "amoCRM"], "budget": {"from": 1600, "to": 2200, "currency": "USD"},
"contacts": "@crm_head", "is_vacancy": True, "board": None, "is_spam": False}),
("Frontend-разработчик в продуктовую команду, Vue 3, 2 400$.",
{"title": "Frontend-разработчик в продуктовую команду", "summary": "Развитие SPA: компоненты, стейт, производительность.",
"stack": ["Vue 3", "Pinia", "Vitest"], "budget": {"from": 2400, "to": 2400, "currency": "USD"},
"contacts": "@front_hh", "is_vacancy": True, "board": None, "is_spam": False}),
("Ищу подрядчика на бота для такси: клиент заказывает, водитель принимает. Бюджет обсуждаем.",
{"title": "Бот для заказов такси", "summary": "Клиент заказывает, водитель принимает.",
"stack": [], "budget": None, "contacts": "@taxi_owner", "is_vacancy": False, "board": None, "is_spam": False}),
]
def _lead_or_404(lead_id: str) -> dict:
lead = leads_svc.get_lead(lead_id)
if not lead:
raise HTTPException(404, "Карточка не найдена")
return lead
# ─── Доски ────────────────────────────────────────────────────────────────
@router.get("/boards")
def boards(_: str = Depends(current_login)) -> list[dict]:
return leads_svc.list_boards()
@router.post("/boards")
def create_board(body: BoardCreate, _: str = Depends(current_login)) -> dict:
return leads_svc.create_board(
name=body.name,
color=body.color,
keywords=body.keywords,
prompt=body.prompt,
description=body.description,
rules=body.rules,
)
@router.patch("/boards/{board_id}")
def patch_board(board_id: str, body: BoardPatch, _: str = Depends(current_login)) -> dict:
try:
return leads_svc.patch_board(board_id, body.model_dump(exclude_none=True))
except KeyError:
raise HTTPException(404, "Доска не найдена")
@router.delete("/boards/{board_id}")
def delete_board(board_id: str, _: str = Depends(current_login)) -> dict:
moved = leads_svc.delete_board(board_id)
return {"ok": True, "movedToInbox": moved}
@router.post("/boards/reorder")
def reorder(body: OrderBody, _: str = Depends(current_login)) -> dict:
leads_svc.reorder_boards(body.order)
return {"ok": True}
# ─── Состояние колонок ────────────────────────────────────────────────────
@router.get("/columns/state")
def columns_state(_: str = Depends(current_login)) -> dict:
return leads_svc.get_col_state()
@router.patch("/columns/{col_id}/state")
def set_column_state(col_id: str, body: ColStateBody, _: str = Depends(current_login)) -> dict:
state = leads_svc.get_col_state().get(col_id, {})
state.update(body.model_dump(exclude_none=True))
return leads_svc.set_col_state(col_id, state)
# ─── Лиды ─────────────────────────────────────────────────────────────────
@router.get("/leads")
def list_leads(col: str | None = None, _: str = Depends(current_login)) -> dict:
if col and col not in ("inbox", "archive", "trash") and not any(b["id"] == col for b in leads_svc.list_boards()):
raise HTTPException(400, "Неизвестная колонка")
return {"items": leads_svc.list_leads(col)}
@router.get("/leads/counts")
def leads_counts(_: str = Depends(current_login)) -> dict:
return leads_svc.counts()
@router.get("/leads/{lead_id}")
def get_lead(lead_id: str, _: str = Depends(current_login)) -> dict:
return _lead_or_404(lead_id)
@router.post("/leads/{lead_id}/seen")
def seen(lead_id: str, _: str = Depends(current_login)) -> dict:
leads_svc.mark_seen(lead_id=lead_id)
return {"ok": True}
@router.post("/leads/mark-all-seen")
def mark_all_seen(_: str = Depends(current_login)) -> dict:
leads_svc.mark_seen()
return {"ok": True}
class MarkColBody(BaseModel):
col: str
@router.post("/leads/mark-col-seen")
def mark_col_seen(body: MarkColBody, _: str = Depends(current_login)) -> dict:
"""Снять «новое» с колонки (используется при открытии колонки из сайдбара)."""
leads_svc.mark_seen(col=body.col)
return {"ok": True}
@router.post("/leads/{lead_id}/move")
def move(lead_id: str, body: MoveBody, _: str = Depends(current_login)) -> dict:
try:
leads_svc.move_lead(lead_id, body.to)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return _lead_or_404(lead_id)
@router.post("/leads/{lead_id}/trash")
def trash(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
leads_svc.trash_lead(lead_id)
return {"ok": True}
@router.post("/leads/{lead_id}/restore")
def restore(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
back = leads_svc.restore_lead(lead_id)
return {"ok": True, "col": back}
@router.delete("/leads/{lead_id}")
def delete(lead_id: str, _: str = Depends(current_login)) -> dict:
_lead_or_404(lead_id)
leads_svc.delete_forever(lead_id)
return {"ok": True}
class ClearColBody(BaseModel):
col: str
@router.post("/leads/clear-col")
def clear_col(body: ClearColBody, _: str = Depends(current_login)) -> dict:
"""Ручная полная очистка корзины/архива (безвозвратно)."""
try:
cleared = leads_svc.clear_col(body.col)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "cleared": cleared}
@router.post("/leads/{lead_id}/comments")
def comment(lead_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
if not body.text.strip():
raise HTTPException(400, "Пустой комментарий")
return {"comments": leads_svc.add_comment(lead_id, body.text)}
@router.post("/leads/reclassify")
async def reclassify(body: ReclassifyBody | None = None, _: str = Depends(current_login)) -> dict:
"""Переклассификация «Неразобранного»: фоновая задача (одна за раз)."""
ids = body.ids if body else None
return await leads_svc.start_reclassify(ids)
# ─── Поиск ────────────────────────────────────────────────────────────────
@router.get("/search")
def search(q: str = "", _: str = Depends(current_login)) -> dict:
return leads_svc.search(q)
# ─── Админ-тик (правила хранения) ─────────────────────────────────────────
@router.post("/admin/fts/rebuild")
def fts_rebuild(_: str = Depends(current_login)) -> dict:
ok = fts_svc.rebuild()
return {"ok": ok, "ready": fts_svc.is_ready()}
@router.post("/admin/check-message")
async def check_message(body: CheckMessageBody, _: str = Depends(current_login)) -> dict:
"""Проверка фильтра входящих (этап 1 + этап 2) для тестера в настройках."""
from ..services.ai import filter_incoming
r1 = stage1_plain(body.text)
result = {"stage1": {"pass": r1["pass"], "reason": r1["reason"]}}
if not r1["pass"]:
result["stage2"] = {"pass": False, "reason": None, "skipped": True}
result["passed"] = False
return result
try:
r2 = await filter_incoming(body.text)
except Exception as exc: # noqa: BLE001
r2 = {"pass": True, "reason": None, "skipped": True}
result["stage2"] = r2
result["passed"] = bool(r2["pass"])
return result
def _demo_off() -> None:
if not config.DEMO_ENABLED:
raise HTTPException(404, "Демо-режим отключён")
@router.post("/demo/simulate-lead")
async def simulate_lead(_: str = Depends(current_login)) -> dict:
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
_demo_off()
import random
text, raw = random.choice(_DEMO_POOL)
digest = "demo_" + secrets.token_hex(8)
lead = _store_lead(
digest, "demo_channel", "Демо-канал", "demo_channel", "#8b8ff8", text, raw, time.time_ns() // 1_000_000,
)
if lead:
await broker.publish("new_lead", lead)
await broker.publish_toast("Демо: новый лид", "sparkles")
return lead or {}
@router.post("/demo/age-lead")
async def demo_age_lead(_: str = Depends(current_login)) -> dict:
"""Служебный демо-эндпоинт (включён только при LEADRADAR_DEMO=1)."""
_demo_off()
days = int(store.get_setting("archiveAfterDays") or 14)
row = store.query_one(
"SELECT id FROM leads WHERE col IN (SELECT id FROM boards) ORDER BY received_at ASC LIMIT 1"
)
if not row:
raise HTTPException(400, "Нет карточек на досках для демо")
aged = time.time_ns() // 1_000_000 - (days + 1) * C.DAY_MS
store.execute("UPDATE leads SET received_at = ? WHERE id = ?", [aged, row["id"]])
stats = leads_svc.tick_storage()
if stats["archived"]:
await broker.publish_toast(f"Демо: карточка → Архив (старше {days + 1} дн.)", "clock")
return {"ok": True, "stats": stats}
@router.post("/admin/tick")
async def admin_tick(_: str = Depends(current_login)) -> dict:
from ..services import projects as projects_svc
from ..services.pipeline import pump_once, queue_len
stats = leads_svc.tick_storage()
await leads_svc.notify_tick_stats(stats)
due = await projects_svc.check_reminders()
# разгребаем очередь входящих (этап 1 -> правила -> ML/ИИ)
pump = await pump_once()
return {"storage": stats, "reminders": due, "pipeline": pump, "queue": queue_len()}
@router.post("/admin/wipe")
async def admin_wipe(_: str = Depends(current_login)) -> dict:
"""Полный сброс под новый прогон: все карточки, обучение ML, счётчики ИИ/ML.
Удаляет карточки со всех колонок (включая архив/корзину), исходные
сообщения, журнал обучения, очередь обучения и очередь входящих; сбрасывает
модель ML и счётчики реальных действий. Колонки/доски, каналы и настройки
не трогаются.
"""
from ..services import ml_client
from ..services.ml_client import DECISIONS_AI, DECISIONS_ML
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
for tbl in ("leads", "dedup", "messages", "learning_log", "ml_outbox", "pipeline_msg", "rejected_msgs"):
store.execute(f"DELETE FROM {tbl}")
store.set_setting(DECISIONS_ML, 0)
store.set_setting(DECISIONS_AI, 0)
fts_svc.rebuild()
ml = await ml_client.reset_model()
return {"ok": True, "cardsRemoved": n_leads, "ml": ml}
@router.post("/admin/clear-cards")
async def admin_clear_cards(_: str = Depends(current_login)) -> dict:
"""Очистить карточки и очереди для повторного прогона, НЕ трогая ML.
Удаляет карточки (все колонки/архив/корзина), исходники, дедуп, журнал
обучения и очередь входящих. Модель ML, очередь её обучения (ml_outbox)
и счётчики ИИ/ML остаются нетронутыми — нужно для тестов «обучить ML на
прогоне с ИИ, затем прогнать те же сообщения без ИИ».
"""
n_leads = int(store.scalar("SELECT count(*) FROM leads") or 0)
for tbl in ("leads", "dedup", "messages", "learning_log", "pipeline_msg", "rejected_msgs"):
store.execute(f"DELETE FROM {tbl}")
fts_svc.rebuild()
return {"ok": True, "cardsRemoved": n_leads}
@router.post("/admin/pump-gate")
def pump_gate(body: PumpGateBody, _: str = Depends(current_login)) -> dict:
"""Шлагбаум пайплайна: остановить воркер после N созданных карточек.
limit=0 или None — снять ограничение (воркер продолжит разбирать очередь);
limit>0 — обнулить счётчик и останавливаться после N карточек (проверка
результата: «прогон с остановкой после первых N сообщений»).
"""
if body.limit is not None:
v = max(0, int(body.limit))
store.set_setting("pumpGate", v)
store.set_setting("pumpGateDone", 0)
limit = int(store.get_setting("pumpGate") or 0)
done = int(store.get_setting("pumpGateDone") or 0)
return {"ok": True, "limit": limit, "done": done}
@router.post("/ai/suggest-columns")
async def ai_suggest_columns(_: str = Depends(current_login)) -> dict:
"""Ручной запуск анализа «Неразобранного»: ИИ предлагает колонки."""
from ..services.suggest import suggest_from_inbox
result = await suggest_from_inbox(force=True)
return result
@router.post("/ai/suggest-keywords")
async def ai_suggest_keywords(_: str = Depends(current_login)) -> dict:
"""ИИ предлагает общие ключевые слова-маркеры сферы по вашим карточкам."""
from ..services.suggest import suggest_domain_keywords
return await suggest_domain_keywords()
@@ -0,0 +1,285 @@
"""API Discovery (Task 7): задачи поиска каналов, кандидаты, чёрный список, лог.
Prefix /api/discovery, авторизация — current_login (как в соседних роутерах).
Сервис discovery отдаёт наружу camelCase-словари (см. его docstring), поэтому
Pydantic-модели повторяют имена полей API без алиасов (как PreviewBody в tg_routes).
Списки наружу — {"items": [...]}, единичные объекты — как есть (конвенция проекта).
Обработка ошибок контракта: ValueError -> HTTP 400, KeyError -> HTTP 404.
"""
from __future__ import annotations
import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..db import store
from ..services import ai as ai_service
from ..services import discovery
from ..services.telegram import _spawn, tg
log = logging.getLogger("leadradar.discovery_api")
router = APIRouter(prefix="/api/discovery", tags=["discovery"])
# потолок текста описания, уходящего ИИ-генератору ключей
_AI_DESCRIPTION_LIMIT = 4000
# страховочный потолок числа сгенерированных ключей (промпт просит 10–16)
_KEYWORDS_LIMIT = 30
# потолок длины одного ключа (короткие фразы для поиска Telegram)
_KEYWORD_LENGTH_LIMIT = 60
# промпт генерации ключевых слов по описанию задачи (RU+EN, глобальный поиск)
_KEYWORDS_PROMPT = (
"Ты — эксперт по поиску Telegram-каналов и групп. По описанию ниши/задачи "
"составь поисковые ключевые слова, по которым в глобальном поиске Telegram "
"находят подходящие источники. Верни строго JSON вида "
'{"keywords": ["...", "..."]}. Требования к списку:\n'
"- 1016 ключей;\n"
"- примерно поровну русских и английских (английские — популярные в нише термины);\n"
"- короткие фразы 1–4 слова;\n"
"- без #, @, кавычек и лишней пунктуации;\n"
"- конкретные для ниши, включая сленг заказчиков и подрядчиков;\n"
"- без дублей и близких по смыслу повторов."
)
class TaskCreate(BaseModel):
name: str
description: str = ""
keywords: list[str] = []
minSubscribers: int = 0
lang: str = "ru"
threshold: int | None = None
sampleSize: int | None = None
planJoins: int = 1
autoJoin: bool = False
class TaskPatch(BaseModel):
name: str | None = None
description: str | None = None
keywords: list[str] | None = None
minSubscribers: int | None = None
lang: str | None = None
threshold: int | None = None
sampleSize: int | None = None
planJoins: int | None = None
autoJoin: bool | None = None
def _payload(body: BaseModel) -> dict:
"""Поля модели -> payload сервиса, без явных None.
discovery.create_task/patch_task сами подставляют значения по умолчанию
(в т.ч. threshold/sampleSize из настроек), поэтому None-поля пропускаем.
"""
return body.model_dump(exclude_none=True)
def _task_or_404(task_id: str) -> dict:
task = discovery.get_task(task_id)
if task is None:
raise HTTPException(404, "Задача не найдена")
return task
def _candidate_or_404(dialog_id: str) -> dict:
row = store.query_one("SELECT * FROM disc_candidates WHERE dialog_id = ?", [dialog_id])
if row is None:
raise HTTPException(404, "Кандидат не найден")
return row
def _ai_unavailable_reason() -> str | None:
"""Причина недоступности ИИ (None — можно вызывать)."""
if not store.get_setting("aiEnabled"):
return "ИИ выключен в настройках (aiEnabled)"
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — статус не читается = ИИ недоступен
log.debug("generate-keywords: статус ИИ недоступен (%s)", exc)
return "Не удалось прочитать статус ИИ-провайдера"
if not (status.get("local") or status.get("keySet")):
return "Не задан API-ключ ИИ-провайдера"
return None
def _clean_keywords(raw) -> list[str]:
"""Ключи из ответа ИИ: строки без пустых/длинных и повторов (casefold)."""
seen: set[str] = set()
out: list[str] = []
for item in raw or []:
if not isinstance(item, str):
continue
keyword = item.strip()
if not keyword or len(keyword) > _KEYWORD_LENGTH_LIMIT:
continue
key = keyword.casefold()
if key in seen:
continue
seen.add(key)
out.append(keyword)
if len(out) >= _KEYWORDS_LIMIT:
break
return out
async def _backfill_quiet(dialog_id: str) -> None:
"""Догон последних сообщений вступившего источника (фон, best-effort)."""
try:
await tg.backfill_dialog(dialog_id)
except Exception as exc: # noqa: BLE001 — вступление уже состоялось
log.warning("join %s: backfill не удался: %s", dialog_id, exc)
# ─── задачи ────────────────────────────────────────────────────────────────
@router.get("/tasks")
def list_tasks(_: str = Depends(current_login)) -> dict:
return {"items": discovery.list_tasks()}
@router.post("/tasks")
def create_task(body: TaskCreate, _: str = Depends(current_login)) -> dict:
try:
return discovery.create_task(_payload(body))
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.patch("/tasks/{task_id}")
def patch_task(task_id: str, body: TaskPatch, _: str = Depends(current_login)) -> dict:
try:
return discovery.patch_task(task_id, _payload(body))
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete("/tasks/{task_id}")
def delete_task(task_id: str, _: str = Depends(current_login)) -> dict:
_task_or_404(task_id)
discovery.delete_task(task_id)
return {"ok": True}
@router.post("/tasks/{task_id}/start")
def start_task(task_id: str, _: str = Depends(current_login)) -> dict:
try:
return discovery.start_task(task_id)
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/tasks/{task_id}/pause")
def pause_task(task_id: str, _: str = Depends(current_login)) -> dict:
try:
return discovery.pause_task(task_id)
except KeyError as exc:
raise HTTPException(404, "Задача не найдена") from exc
@router.post("/tasks/{task_id}/generate-keywords")
async def generate_keywords(task_id: str, _: str = Depends(current_login)) -> dict:
"""ИИ-генерация ключей по описанию задачи: RU+EN, 10–16 строк.
ИИ выключен/не настроен/ответил ошибкой — {"keywords": [], "error": "..."}
с HTTP 200, чтобы UI показал причину, а не падал.
"""
task = _task_or_404(task_id)
reason = _ai_unavailable_reason()
if reason:
return {"keywords": [], "error": reason}
description = str(task.get("description") or "").strip()
if not description:
return {"keywords": [], "error": "У задачи нет описания — по нему генерируются ключи"}
try:
out = await ai_service.chat_json(
_KEYWORDS_PROMPT,
f"Описание ниши/задачи:\n{description[:_AI_DESCRIPTION_LIMIT]}",
)
except Exception as exc: # noqa: BLE001 — сбой провайдера не роняет API
log.warning("generate-keywords задача %s: ИИ не ответил: %s", task_id, exc)
return {"keywords": [], "error": str(exc)}
return {"keywords": _clean_keywords(out.get("keywords", []) if isinstance(out, dict) else [])}
# ─── кандидаты ─────────────────────────────────────────────────────────────
@router.get("/tasks/{task_id}/candidates")
def list_candidates(
task_id: str,
status: Literal["new", "review", "joined", "rejected"] | None = None,
_: str = Depends(current_login),
) -> dict:
_task_or_404(task_id)
return {"items": discovery.list_candidates(task_id, status)}
@router.post("/candidates/{dialog_id}/join")
async def join_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
"""Ручное вступление (вне квот и пауз воркера).
tg.discovery_join -> add_dialog_monitored -> backfill_dialog (последние
сообщения, best-effort) -> mark_joined(auto=False); источник снимается с
чёрного списка. Ошибка Telegram -> 400 с текстом причины.
"""
row = _candidate_or_404(dialog_id)
if row["status"] == "joined":
raise HTTPException(400, "Уже вступили в этот источник")
username = str(row.get("username") or "")
try:
await tg.discovery_join(username)
except Exception as exc: # текст ошибки уходит наружу
raise HTTPException(400, f"Не удалось вступить в @{username}: {exc}") from exc
tg.add_dialog_monitored(dialog_id, row.get("name"), username, row.get("kind"), row.get("hue"))
# догон последних сообщений — в фоне: join из UI не должен висеть на
# паузах backfill (10 сообщений × 1.5–3 с); источник уже в мониторинге
_spawn(_backfill_quiet(dialog_id))
discovery.remove_blacklist(dialog_id)
try:
return discovery.mark_joined(dialog_id, auto=False)
except KeyError as exc:
raise HTTPException(404, "Кандидат не найден") from exc
@router.post("/candidates/{dialog_id}/reject")
def reject_candidate(dialog_id: str, _: str = Depends(current_login)) -> dict:
"""Отклонить кандидата (в чёрный список). Уже вступившего — нельзя."""
row = _candidate_or_404(dialog_id)
if row["status"] == "joined":
raise HTTPException(400, "Уже вступили — удалите источник из каналов")
try:
return discovery.mark_rejected(dialog_id, reason="отклонено вручную")
except KeyError as exc:
raise HTTPException(404, "Кандидат не найден") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
# ─── чёрный список ─────────────────────────────────────────────────────────
@router.get("/blacklist")
def list_blacklist(_: str = Depends(current_login)) -> dict:
return {"items": discovery.list_blacklist()}
@router.delete("/blacklist/{dialog_id}")
def remove_blacklist(dialog_id: str, _: str = Depends(current_login)) -> dict:
discovery.remove_blacklist(dialog_id)
return {"ok": True}
# ─── лог задачи ────────────────────────────────────────────────────────────
@router.get("/tasks/{task_id}/log")
def task_log(task_id: str, _: str = Depends(current_login)) -> dict:
_task_or_404(task_id)
return {"items": discovery.task_log(task_id)}
@@ -0,0 +1,38 @@
"""SSE-события (realtime) для фронтенда."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from ..auth import current_login
from ..sse import broker
router = APIRouter(prefix="/api", tags=["events"])
@router.get("/events")
async def events(request: Request, _: str = Depends(current_login)):
async def stream():
q = await broker.subscribe()
try:
while True:
if await request.is_disconnected():
break
try:
yield await asyncio.wait_for(q.get(), timeout=15)
except asyncio.TimeoutError:
yield ": ping\n\n"
finally:
await broker.unsubscribe(q)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@@ -0,0 +1,171 @@
"""ML-лаборатория: статус, проверка на сообщении, обучение на канале.
Всё обучение уходит в outbox (гарантированно), фоновый цикл отправляет его
в автономный ML-сервис. Использование ML в пайплайне — только по настройке
`mlEnabled`; здесь можно проверить модель и вручную разметить сообщения.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..db import store
from ..services import leads as leads_svc
from ..services import ml_client
from ..services.telegram import tg
router = APIRouter(prefix="/api/ml", tags=["ml"])
class PredictBody(BaseModel):
text: str
class LearnBody(BaseModel):
text: str
label: str
class CandidatesBody(BaseModel):
dialogId: str
limit: int = 10
class ApplyBody(BaseModel):
dialogId: str
msgId: int
action: str # 'spam' | 'board:<id>' | 'skip'
def _msg_text(dialog_id: str, msg_id: int) -> str | None:
"""Текст исходного сообщения (из карточки или из messages)."""
row = store.query_one(
"SELECT source_msg AS text FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
[dialog_id, msg_id],
)
if row and row["text"]:
return row["text"]
m = store.query_one("SELECT text FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
return m["text"] if m else None
def _lead_by_msg(dialog_id: str, msg_id: int) -> dict | None:
row = store.query_one(
"SELECT id FROM leads WHERE source_dialog_id = ? AND source_msg_id = ? LIMIT 1",
[dialog_id, msg_id],
)
if row:
return leads_svc.get_lead(row["id"])
link = store.query_one("SELECT lead_id FROM messages WHERE id = ?", [f"m_{dialog_id}_{msg_id}"])
if link and link["lead_id"]:
return leads_svc.get_lead(link["lead_id"])
return None
@router.get("/status")
async def ml_status(_: str = Depends(current_login)) -> dict:
"""Свежий статус ML-сервиса + локальная статистика (с принудительным refresh)."""
svc = await ml_client.refresh_status()
return {
"enabled": store.get_setting("mlEnabled") is not False,
"service": svc,
"reachable": ml_client._cached["reachable"], # noqa: SLF001
"stats": ml_client.snapshot(),
}
@router.post("/reset")
async def ml_reset(_: str = Depends(current_login)) -> dict:
"""Полный сброс ML-модели (классы/термины) + очистка очереди обучения."""
return await ml_client.reset_model()
@router.post("/predict")
async def ml_predict(body: PredictBody, _: str = Depends(current_login)) -> dict:
text = (body.text or "").strip()
if len(text) < 2:
raise HTTPException(400, "Введите текст")
result = await ml_client.predict(text)
return {"text": text[:200], **result}
@router.post("/learn")
async def ml_learn(body: LearnBody, _: str = Depends(current_login)) -> dict:
"""Ручная разметка: «это сообщение -> сюда». Пишется в outbox (всегда)."""
text = (body.text or "").strip()
label = (body.label or "").strip()
if not text or not label:
raise HTTPException(400, "text и label обязательны")
ml_client.push(text, label)
return {"ok": True, "outbox": ml_client.outbox_len()}
@router.post("/flush")
async def ml_flush(_: str = Depends(current_login)) -> dict:
"""Отправить накопленное обучение в ML-сервис немедленно (обычно — фон раз в 10 c)."""
flushed = await ml_client.flush_outbox()
svc = await ml_client.refresh_status()
return {"ok": True, "flushed": flushed, "outbox": ml_client.outbox_len(), "service": svc}
@router.post("/candidates")
async def ml_candidates(body: CandidatesBody, _: str = Depends(current_login)) -> dict:
"""Последние сообщения канала для разбора/обучения + мнение ML по каждому."""
limit = max(1, min(body.limit, 60))
items = await tg.dialog_messages(body.dialogId, limit)
out = []
for m in items:
text = (m.get("text") or "").strip()
if not text:
continue
pred = await ml_client.predict(text) if ml_client.is_enabled() else {
"take": False, "label": None, "scores": {}, "ready": False}
out.append(
{
"id": m["id"],
"dialogId": body.dialogId,
"text": text[:600],
"time": m.get("time"),
"lead": bool(m.get("lead")),
"pred": {"take": pred.get("take"), "label": pred.get("label"), "scores": pred.get("scores", {})},
}
)
return {"items": out}
@router.post("/apply")
async def ml_apply(body: ApplyBody, _: str = Depends(current_login)) -> dict:
"""Ручное решение по сообщению: учим ML и (если карточка есть) двигаем её."""
text = _msg_text(body.dialogId, body.msgId)
if not text:
raise HTTPException(404, "Исходное сообщение не найдено")
action = body.action
if action == "skip":
return {"ok": True, "learned": False, "moved": None}
lead = _lead_by_msg(body.dialogId, body.msgId)
result: dict = {"ok": True, "learned": False, "moved": None, "leadId": None}
if action == "spam":
ml_client.push(text, "spam")
result["learned"] = True
if lead:
leads_svc.trash_lead(lead["id"], teach=False)
result["moved"] = "trash"
result["leadId"] = lead["id"]
elif action.startswith("board:"):
board_id = action.split(":", 1)[1]
if board_id != "inbox" and store.query_one("SELECT 1 FROM boards WHERE id = ?", [board_id]) is None:
raise HTTPException(400, "Неизвестная доска")
ml_client.push(text, board_id)
result["learned"] = True
if lead:
# повторная разметка карточки, которая уже на доске, — просто обучение
if lead["col"] != board_id:
leads_svc.move_lead(lead["id"], board_id, teach=False)
result["moved"] = board_id
result["leadId"] = lead["id"]
else:
raise HTTPException(400, "Неизвестное действие")
return result
@@ -0,0 +1,74 @@
"""Мониторинг пайплайна — вкладка «Обработка» (очередь и отсев)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import current_login
from ..services import processing as processing_svc
router = APIRouter(prefix="/api/pipeline", tags=["processing"])
class ReturnBody(BaseModel):
reason: str = ""
@router.get("/stats")
def stats(_: str = Depends(current_login)) -> dict:
"""Сводка: размер очереди (new/ai) и число записей в отсеве."""
return processing_svc.stats()
@router.get("/queue")
def queue(limit: int = 100, _: str = Depends(current_login)) -> dict:
"""Сырые сообщения, ожидающие обработки (этап 1 или ИИ)."""
q = processing_svc.queue_counts()
return {
"items": processing_svc.list_queue(limit),
"counts": q,
"rejected": processing_svc.rejected_count(),
}
@router.get("/rejected")
def rejected(
q: str = "",
offset: int = 0,
limit: int = 100,
_: str = Depends(current_login),
) -> dict:
"""Отсев: что и почему не прошло пайплайн (полнотекстовый поиск по q)."""
return processing_svc.list_rejected(q, offset, limit)
@router.post("/rejected/clear")
def rejected_clear(_: str = Depends(current_login)) -> dict:
"""Ручная полная очистка отсева (безвозвратно)."""
cleared = processing_svc.clear_all()
return {"ok": True, "cleared": cleared}
@router.delete("/rejected/{rej_id}")
def rejected_delete(rej_id: str, _: str = Depends(current_login)) -> dict:
processing_svc.delete_one(rej_id)
return {"ok": True}
@router.post("/rejected/{rej_id}/return")
def rejected_return(rej_id: str, body: ReturnBody, _: str = Depends(current_login)) -> dict:
"""Вернуть отсеянное сообщение в обработку.
Для возвращённого сообщения причины отсева (стоп-лист, резюме, тип,
без суммы, устарело, ML/ИИ-спам) игнорируются: оно уходит на ML/ИИ
и создаёт карточку. ML обучается на действии (снятие «спама»), а решение
ИИ по возвращённому сообщению снова учит ML. Запись в отсеве помечается
«возвращено» с указанной пользователем причиной.
"""
try:
out = processing_svc.return_to_queue(rej_id, body.reason)
except KeyError as exc:
raise HTTPException(404, "Запись не найдена") from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return out
@@ -0,0 +1,211 @@
"""«Выбранные»: проектные карточки, стадии, история, напоминания, вложения."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from ..auth import current_login
from ..services import files as files_svc
from ..services import object_store
from ..services import projects as proj_svc
router = APIRouter(prefix="/api/projects", tags=["projects"])
class TakeBody(BaseModel):
leadId: str
class CreateBody(BaseModel):
title: str = ""
summary: str = ""
stack: list[str] | None = None
budget: dict | None = None
contact: str = ""
tzText: str = ""
stage: str | None = None
class PatchBody(BaseModel):
title: str | None = None
summary: str | None = None
stack: list[str] | None = None
budget: dict | None = None
contact: str | None = None
tzText: str | None = None
class StageBody(BaseModel):
stage: str
class CommentBody(BaseModel):
text: str
class LinkBody(BaseModel):
name: str = ""
url: str
class ReminderBody(BaseModel):
at: int # epoch ms
def _card_or_404(card_id: str) -> dict:
card = proj_svc.get_card(card_id)
if not card:
raise HTTPException(404, "Карточка не найдена")
return card
@router.get("")
def list_cards(stage: str | None = None, _: str = Depends(current_login)) -> dict:
return {"items": proj_svc.list_cards(stage)}
@router.get("/reminders")
def reminders(_: str = Depends(current_login)) -> dict:
return {"items": proj_svc.active_reminders()}
@router.get("/{card_id}")
def get_card(card_id: str, _: str = Depends(current_login)) -> dict:
return _card_or_404(card_id)
@router.post("")
def create_card(body: CreateBody, _: str = Depends(current_login)) -> dict:
return proj_svc.create_local_card(body.model_dump())
@router.post("/take")
def take(body: TakeBody, _: str = Depends(current_login)) -> dict:
"""«Взять в работу» — лид безвозвратно уходит с дашборда."""
try:
return proj_svc.take_lead_to_projects(body.leadId)
except KeyError:
raise HTTPException(404, "Лид не найден")
@router.post("/clear-rejected")
def clear_rejected(_: str = Depends(current_login)) -> dict:
"""Полная очистка стадии «Отклонено» (безвозвратно)."""
try:
cleared = proj_svc.clear_stage("rejected")
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"ok": True, "cleared": cleared}
@router.patch("/{card_id}")
def patch(card_id: str, body: PatchBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
return proj_svc.patch_card(card_id, body.model_dump(exclude_none=True))
@router.delete("/{card_id}")
def delete(card_id: str, _: str = Depends(current_login)) -> dict:
# По решению удаление проектных карточек не делаем — карточка живёт
# до финального статуса «Выполнено»/«Отклонено».
raise HTTPException(400, "Удаление проектных карточек отключено")
@router.post("/{card_id}/move")
def move(card_id: str, body: StageBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
try:
return proj_svc.move_stage(card_id, body.stage)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/{card_id}/comments")
def comment(card_id: str, body: CommentBody, _: str = Depends(current_login)) -> dict:
if not body.text.strip():
raise HTTPException(400, "Пустой комментарий")
return {"comments": proj_svc.add_comment(card_id, body.text)}
# ─── Ссылки ───────────────────────────────────────────────────────────────
@router.post("/{card_id}/links")
def add_link(card_id: str, body: LinkBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
url = body.url.strip()
if not url:
raise HTTPException(400, "Пустая ссылка")
if not url.startswith(("http://", "https://")):
url = "https://" + url
card = _card_or_404(card_id)
links = card["links"] + [{"id": f"pl_{card_id[:6]}_{len(card['links'])}", "name": body.name.strip() or url, "url": url}]
return proj_svc.patch_card(card_id, {"links": links})
@router.delete("/{card_id}/links/{link_id}")
def remove_link(card_id: str, link_id: str, _: str = Depends(current_login)) -> dict:
card = _card_or_404(card_id)
links = [l for l in card["links"] if l["id"] != link_id]
return proj_svc.patch_card(card_id, {"links": links})
# ─── Вложения (мета; MinIO позже) ─────────────────────────────────────────
@router.post("/{card_id}/files")
async def upload_files(card_id: str, files: list[UploadFile], _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
added = []
for f in files:
content = await f.read()
added.append(files_svc.add_file(card_id, f.filename or "file", content, f.content_type or ""))
return {"items": added}
@router.get("/{card_id}/files/{file_id}/download")
def download_file(card_id: str, file_id: str, _: str = Depends(current_login)):
entry, _ = files_svc.get_file_entry(card_id, file_id)
if not entry.get("objectKey"):
raise HTTPException(410, "Файл не сохранён в объектном хранилище")
try:
stream = object_store.get(entry["objectKey"])
except Exception as exc: # noqa: BLE001
raise HTTPException(404, "Файл не найден в MinIO") from exc
safe_name = entry["name"].replace('"', "")
return StreamingResponse(
stream,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
)
@router.delete("/{card_id}/files/{file_id}")
def remove_file(card_id: str, file_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
files_svc.remove_file(card_id, file_id)
return {"ok": True}
# ─── Напоминания об «Отложено» ────────────────────────────────────────────
@router.post("/{card_id}/reminder")
def set_reminder(card_id: str, body: ReminderBody, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
try:
return proj_svc.set_reminder(card_id, body.at)
except PermissionError as exc:
raise HTTPException(400, str(exc)) from exc
@router.delete("/{card_id}/reminder")
def clear_reminder(card_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
proj_svc.clear_reminder(card_id)
return {"ok": True}
@router.post("/{card_id}/reminder/snooze")
def snooze(card_id: str, _: str = Depends(current_login)) -> dict:
_card_or_404(card_id)
proj_svc.snooze(card_id)
return {"ok": True}
@@ -0,0 +1,243 @@
"""Настройки, 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,
}
@@ -0,0 +1,154 @@
"""Telegram: статус, веб-авторизация, диалоги и мониторинг (п.4.2, 4.3 ТЗ)."""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from ..auth import current_login
from ..services.telegram import tg
router = APIRouter(prefix="/api/tg", tags=["telegram"])
def _qr_svg(url: str) -> str:
"""Реальный QR-код (SVG, без внешних растровых зависимостей)."""
import io
import qrcode
import qrcode.image.svg
qr = qrcode.QRCode(version=None, box_size=12, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(url)
qr.make(fit=True)
buf = io.StringIO()
qr.make_image(image_factory=qrcode.image.svg.SvgPathImage).save(buf)
return buf.getvalue()
@router.get("/qr-image")
def qr_image(_: str = Depends(current_login)) -> Response:
"""SVG-картинка QR для сканирования (фаза входа 'qr')."""
if tg.phase != "qr" or not tg.qr_url:
raise HTTPException(404, "QR не активен — начните вход по QR")
return Response(
_qr_svg(tg.qr_url),
media_type="image/svg+xml",
headers={"Cache-Control": "no-store", "Content-Disposition": "inline"},
)
class PhoneBody(BaseModel):
phone: str
class CodeBody(BaseModel):
code: str
class PasswordBody(BaseModel):
password: str
class MonitorBody(BaseModel):
enabled: bool
class PreviewBody(BaseModel):
dialogId: str
limit: int = 24
@router.get("/status")
def status(_: str = Depends(current_login)) -> dict:
return tg.status()
@router.post("/start-phone")
async def start_phone(body: PhoneBody, _: str = Depends(current_login)) -> dict:
try:
await tg.start_phone(body.phone.strip())
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase}
@router.post("/start-qr")
async def start_qr(_: str = Depends(current_login)) -> dict:
try:
url = await tg.qr_start()
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase, "qrUrl": url}
@router.post("/send-code")
async def send_code(body: CodeBody, _: str = Depends(current_login)) -> dict:
try:
await tg.submit_code(body.code.strip())
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(400, tg.error or str(exc)) from exc
return {"phase": tg.phase}
@router.post("/send-password")
async def send_password(body: PasswordBody, _: str = Depends(current_login)) -> dict:
try:
await tg.submit_password(body.password)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"phase": tg.phase}
@router.post("/logout")
async def logout(_: str = Depends(current_login)) -> dict:
await tg.disconnect()
return {"ok": True}
@router.get("/dialogs")
def dialogs(_: str = Depends(current_login)) -> dict:
return {"items": tg.list_dialogs()}
@router.post("/dialogs/refresh")
async def dialogs_refresh(_: str = Depends(current_login)) -> dict:
if not tg.client or not tg.client.is_connected():
return {"ok": False, "reason": "not-connected", "count": 0}
count = await asyncio.wait_for(tg.refresh_dialogs(), timeout=60)
return {"ok": True, "count": count}
@router.post("/dialogs/monitor-all")
def monitor_all(body: MonitorBody, _: str = Depends(current_login)) -> dict:
"""Включить/выключить мониторинг сразу для всех каналов."""
count = tg.set_monitor_all(body.enabled)
return {"ok": True, "count": count, "enabled": body.enabled}
@router.post("/dialogs/backfill-all")
def backfill_all(_: str = Depends(current_login)) -> dict:
"""Перечитать последние 10 сообщений всех включённых каналов (кнопка)."""
count = tg.backfill_monitored()
return {"ok": True, "count": count}
@router.post("/dialogs/{dialog_id}/monitor")
def set_monitor(dialog_id: str, body: MonitorBody, _: str = Depends(current_login)) -> dict:
tg.set_monitor(dialog_id, body.enabled)
return {"ok": True, "enabled": body.enabled}
@router.post("/dialogs/{dialog_id}/backfill")
async def backfill(dialog_id: str, _: str = Depends(current_login)) -> dict:
processed = await tg.backfill_dialog(dialog_id)
return {"ok": True, "processed": processed}
@router.post("/dialogs/preview")
async def preview(body: PreviewBody, _: str = Depends(current_login)) -> dict:
items = await tg.dialog_messages(body.dialogId, min(max(body.limit, 1), 50))
return {"items": items}