Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -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 600–2 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'
|
||||
"- 10–16 ключей;\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}
|
||||
Reference in New Issue
Block a user